Modding Horizon
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust. A Horizon mod is one .hmod package. It is one of:
| Kind | Holds | Runs |
|---|---|---|
| Content mod | assets/ and data/: blocks, items, recipes, loot, tags, models, textures | read when the game starts |
| Native mod | a Rust crate (Cargo.toml, src/) beside the content, using the mod API | compiled into a game the launcher builds for the player |
| WebAssembly mod | a mod.wasm beside the content, built from the same Rust source | in the game's one sandboxed runtime, with no rebuild |
Native and WebAssembly mods are written against one API, mod_api, and share one dependency model: the only
difference between them is the build target.
How this book is organised
The book follows Diátaxis: each page is one of four kinds, and says so by where it lives: Start (a tutorial), Concepts (explanation), How-to and Reference. See where each doc lives.
An agents page and llms.txt give a coding agent the rules in one place.
Where things are
- The API reference is
cargo doc -p horizon-mod-api: every hook, event andHostmethod, with its side, its NeoForge 26.3 counterpart and its cancel contract. - The end-to-end example mod is horizon-machine-sample.
- Design and milestones: #26 and #44.
Your first native mod
A native mod is one flat Rust project that implements mod_api::Hooks, built by the launcher into the game it runs
in. You need the launcher and a Horizon commit to build against.
-
Make the project:
cargo horizon-mod new my_mod --template nativeIt writes
horizon-mod.toml(the manifest, with the exact build pins under[native]),Cargo.tomlandsrc/lib.rs, which implements one hook. -
Look at the hook. Each method of
Hooksis a hook point; implement the ones the mod needs. Everyimpl Hookscarries#[mod_api::horizon_mod], which subscribes the mod to just the methods in that block:#[mod_api::horizon_mod]
impl Hooks for Mod { fn server_tick(&self, host: &mut dyn ServerHost, : &mut ServerTickEvent<'>) { let _ = count(host); } }
3. Build and run it in the launcher: `horizon-launcher native-build . --instance <instance> --trust-native-code`.
Add `--profile developer` while developing for an unoptimized game crate. The choice is remembered for that instance.
Next: [Native mods](../how-to/native-mods.md) for what the launcher does, [Trait mods](../concepts/trait-mods.md)
for Parts and attachments, and [Injections](../injections.md) for the moment no hook covers.
Installing and using mods
Two kinds of mod run in Horizon:
| Kind | File | What it is | Where it runs |
|---|---|---|---|
| Content mod | .hmod | Data and assets: blocks, items, recipes, loot, tags, models, textures, structures. No code. | Loaded at startup; assets-and-data-only switches can reload live |
| Native mod | .hmod | A flat Rust project using the typed hook API, with optional raw injections. | In a game the launcher builds for the player (native mods) |
Fabric and NeoForge jars do not run as they are: they target Minecraft's Java runtime, and Horizon has none. What carries over is the data and assets inside a jar (modpacks.md).
Mods are off by default. A fresh install has no horizon-mods folder and plays exactly like vanilla 26.3.
Playing with mods
- Install a mod from the launcher's Mods page (mod-browser.md) or the game's Options > Mods >
Browse Mods (content-only packages, checked against the same registry), or put its files by hand in the
game folder (the one holding
options.txt): a content mod's.hmod(or a folder holdinghorizon-mod.toml) inhorizon-mods/. Native mods go inhorizon-native-mods/, and the launcher builds them (native mods). - The Mods button on the title screen (also Options > Mods) opens the mod list, laid out as NeoForge's: Minecraft
and Horizon first, then each installed mod with its icon, name and version. Search the list, sort it A-Z or Z-A, and
pick a mod to read its id, version, license, authors, credits and description. Homepage and Issues open the mod's
links after a confirmation, and Config opens the settings page of a native mod that has one. The screen has no
switch. A mod starts off: the launcher (
horizon-launcher mods enable <id>) orhorizon-mods.txt(mod.<id>:true) turns it on, and a mod that is not on says so in the list. - A turned-on content mod with only assets and reloadable data (textures, models, sounds, languages, recipes, tags,
loot, advancements and functions) is picked up live when a mod is installed from Browse Mods: the game reloads its
resources, then its world's data as
/reloaddoes, including for LAN guests. Content that adds registry entries or changes world generation still needs a restart. Packages with jar layers also wait for a restart, because those layers may contain world generation. On another server all content waits: the server owns its data. - A mod that can't load shows why, in red, in its panel.
Two turned-on content mods never own the same namespace: the first by file name loads, and the other stays out with the reason in the log.
Benchmarks (--benchmark) never load mods.
Horizon's official mods
Horizon's own mods are native. They live in
horizon-rust/horizon-mods under native/<name>/:
| Mod | Folder | Replaces |
|---|---|---|
| FPS Counter | native/fps-overlay | Extras > FPS counter |
| Keystrokes | native/keystrokes | Extras > Keystrokes |
| Zoom | native/zoom | Extras > Zoom |
| Minimap | native/minimap | Extras > Minimap |
| Recipes | native/recipes | The Recipes mod (item panel and recipe pages, on hook points version 2) |
| Coordinates HUD | native/coords-hud | A small example mod (position and facing, /where, /coords) |
The game keeps the parts that must be fast, such as drawing the minimap's map, and a native mod picks the options (extras.md). Far View is not a mod any more: its distance and detail are Graphics > LOD Distance and Far Detail (graphics.md).
Wire types
Native and wasm mods share mod_api::wire::{Wire, Reader, Writer, WireError}.
Derive mod_api::Wire explicitly on a type whose values cross a target boundary.
The build never adds a derive. Types used only inside one target do not need it.
Every field of a derived type must also implement Wire.
#![allow(unused)] fn main() { #[derive(mod_api::Wire)] pub struct ChemicalAmount { pub chemical: String, pub amount: u64, } }
Records encode fields in declaration order. Enums encode a case index followed by
its fields. Lengths and numbers are little-endian; collections are length-prefixed.
Decoding returns WireError for invalid data rather than accepting an unknown tag,
invalid UTF-8 or an unsupported length.
Nesting and stacks
Structural decoding and encoding allow 32 nested records, payload-carrying
variants and Box/Arc values (MAX_DEPTH). Collection iteration, Option
and tuples do not consume a level. The reader returns WireError::Depth before
entering level 33; the writer records the same error. Always check Writer::error
before sending its bytes. This is a transport budget for unbounded mod-defined
trees, including ingredients, item places, loot vehicles and recipe slot displays.
Vanilla trees are not restricted by that structural budget: NBT Tag uses an
explicit work stack with 512 nested containers; Text crosses as the existing
validated flat TextForm table, preserving its 512 component-depth contract.
These encodings are identical on native and wasm. Text's layout identity is the
flat table's identity; mods must rebuild.
JSON-backed serde_json::Value, JSON maps and ModelPart also refuse beyond
32 JSON containers. Their encoders preflight the in-memory tree iteratively
before serde recursion; their decoders scan the JSON's nesting outside quoted
strings before parsing. A model part's JSON includes object, children-list and
named-child-tuple containers, so one part can consume several JSON levels.
The smallest configured guest shadow stack is 256 KiB (horizon-mod-wasm's
shared linker). Wasmtime also has its default 512 KiB native call-stack budget.
Native decoding runs synchronously on the calling thread: world workers use Rust's
normal spawned-thread stack (normally 2 MiB), while integrated server threads
reserve at least 8 MiB. The depth regression tests use 128 KiB, leaving half
the guest shadow stack for callers. An embedding that supplies smaller stacks or
arbitrarily recursive handwritten Wire implementations must enforce its own
stack contract; the reader cannot police code outside its codec.
Wire does not own a borrowed value's destructor. Constructing an arbitrarily deep
Rust tree and then dropping it can still overflow a thread independently of Wire.
Use explicit work stacks when disposing of hostile trees (discard_tag for NBT),
and do not transfer arbitrarily deep owned Rust trees to an encoder. The source
scanner tools/scan_recursive_wire.py and its classification test require review
of every new recursive public vocabulary type.
Checked representations
Use #[wire(via = String)] (or another Wire type) when a checked constructor owns
validation. Implement From<&YourType> for String and TryFrom<String> for YourType.
The derive writes the representation and maps a failed conversion to
WireError::Invalid. Its layout is the representation's layout.
Layout identity
Wire::layout computes an encoding identity recursively, including field names,
case names and child layouts. It is a runtime method, not a constant, because
recursive types need a visited-type stack. Container layout follows encoding:
Vec<T> and VecDeque<T> agree, as do String and Box<str>; Box<T> is
transparent. Changing a crossing field requires rebuilding dependents; the wire
format has no compatibility versions or default fields.
cargo horizon-mod build writes layouts.json at the package root, mapping mod IDs
to public type paths and their 16-digit hexadecimal layout identities. It includes
the mod dependencies it compiled against. A cached host probe under
target/horizon-mod/ computes these values, for both native and wasm builds. When
there are no concrete crossing types, no probe is built and no file is written.
Private types without a public path and uninstantiated generic declarations are
not listed; a public re-export can give a private module's type a public path.
A mixed-target dependent with a different layout is refused naming the library and differing type. Even matching layouts do not enable mixed native and wasm dependencies yet: those edges are refused explicitly. See the extension design for the complete layout contract.
Hand-written implementations
The trait is unsealed. A hand-written implementation must supply put, take,
MIN, and LAYOUT_SEED; compound encodings must override the hidden layout
method to incorporate their children and handle recursive visits. Never use a
constant leaf seed for a compound encoding: field changes must change its identity.
The derive handles this bookkeeping automatically and is preferred.
A hand decoder owns tag validation and must use Reader::nested for recursive
values. Reader checks byte bounds and UTF-8, but cannot enforce invariants that
an implementation neglects. Keep validation at the checked constructor boundary.
Public declarations used in layout discovery must be unconditional. The layout probe refuses
#[cfg] and #[cfg_attr] on public types, modules, and re-exports, naming the declaration.
Keep target-specific implementation details private; publish an unconditional Wire representation.
Trait mods, Parts and attachments
Trait mods (draft)
cargo horizon-mod new <path> --template native pins an exact Horizon commit (the newest prebuilt game's,
or --horizon <commit>'s; cargo horizon-mod update repins) and writes a flat project: horizon-mod.toml with [native], Cargo.toml, src/lib.rs, and empty assets/
and data/ directories. The template greets each player who joins. Build it with
horizon-launcher native-build <path> --trust-native-code; cargo horizon-mod build packages the same
project into a .hmod without compiling it (the manifest).
The root Rust package depends on horizon-mod-api (lib mod_api), never on the game. Its root src/lib.rs marks one
impl Hooks with the attribute:
use mod_api::{self, Hooks};
#[derive(Default)]
struct Mod;
#[mod_api::horizon_mod]
impl Hooks for Mod {
// Implement the hooks this mod needs.
}
The attribute generates the hooks() factory and derives optional hook subscriptions from this impl.
The mod type implements Default; do not write a second factory. Keep the marked impl at the crate root.
Rustc's "cannot find function hooks" at the launcher's generated call means the marked implementation
is not at the crate root. The launcher links this package into a generated game executable and calls
the generated factory in dependency order (a mod after the mods it depends on, else in bundle order).
A mod with no raw injections skips horizon-mixins; typed hooks need no injection declarations.
Dependencies follow the pinned Horizon Cargo.lock rules in The mod's crate.
Dependencies between trait mods
A mod can publish functions and statics from its root crate for another mod to call.
Declare dependencies in horizon-mod.toml, not Cargo.toml:
[dependencies]
base_mod = "^1"
extra_mod = { version = "^2", optional = true }
Requirements use Cargo semver syntax and are checked against each installed mod's horizon-mod.toml
version, not its Cargo package version. Content-only mods use these declarations to validate their
requirements and load their layers after dependencies. Code dependencies additionally require API packages
for the dependent and every installed dependency.
Missing required dependencies, incompatible versions and cycles stop the build with the mods named.
An absent optional dependency is permitted; an installed optional dependency must match its requirement.
The launcher adds a path dependency using the mod ID as the Cargo alias and the target's actual Cargo
package name. Optional dependencies also get a Cargo feature named exactly as their mod ID, enabled
only when that mod is installed. Guard calls with if_installed!, which builds the block only with
that feature and runs it only when the mod is installed, so the same source works when built to
mod.wasm, where the library is linked at load and an absent one's functions trap:
mod_api::if_installed!(host, extra_mod, {
extra_mod::register_extra(host);
});
Do not define these dependencies or features yourself in Cargo.toml. Public statics must use appropriate synchronization for shared and reentrant hooks; private state in another mod's hook object is not exposed.
Trait dispatch follows dependency order, with ties kept in bundle order. Mixin injection order remains horizon-mixins' priority, mod ID, then injection ID for source splices. Function-slot listeners instead use descending priority, dependency/registration order, then injection ID within a mod; call-site listeners retain mod-ID tie ordering.
Load phases and access
Native and wasm hooks share the installed dependency-ordered mod slice, including registration,
config loading and reload-listener registration. The shared resolver keeps bundle-order ties,
checks present optional dependencies too, and refuses cycles for both build targets. Content-only
packages stay after code in the loaded-mod inventory; listener-specific edges still use ReloadOrder.
Each owning thread progresses through Packages → Registration → Setup → Config → World.
| Phase | Available access |
|---|---|
| Packages | Package discovery; no game Host operations. |
| Registration | Declarations through RegistrationContext, with its mod-list, registry and settings reads; no Host. |
| Setup | Client setup reads, published asset/data queries and registry/settings/mod-list reads; no world handles or transactions. |
| Config | Settings reads and the existing mod-scoped ConfigFiles service; no world handles, transactions or other game operations. |
| Reloading | Published asset/data queries, registry facts, settings and mod-list reads, listener registration, and existing DataLoadContext callbacks; no world handles or transactions. |
| World | The owning game's side-typed ServerHost or ClientHost. |
RegistrationContext::instance_dir() reads the instance directory captured before
registration begins, as an InstanceDirectory: Unix(Vec<u8>) preserves raw Unix
path bytes, and Windows(Vec<u16>) preserves Windows UTF-16 code units, including
unpaired surrogates. Never convert Unix bytes with a lossy UTF-8 conversion when
constructing a native filesystem path. Wasm receives the identical host-encoded
value through Wire as data only; this does not grant filesystem access. A context
with no engine snapshot returns RegistrationReadError::Unavailable.
Registration runs once: register, component_types, blocks, items and standalone_models
receive only RegistrationContext. Its mods(), registry(name) and setting(mod_id, key) read
one view captured before the first of them runs, so every registration step sees the same values
whatever the dispatch order: the loaded packages, the built-in registries as registries.json
lists them (not what mods declare during registration) and the settings on disk at that moment. A
mod validates the keys it declares against them from Hooks::register; a name that is not a
namespaced identifier, an unknown registry, an empty or path-like setting key, a package that is
not loaded, or a context the engine gave no data returns a RegistrationReadError. Client setup
runs once after the initial asset apply. The window's Client/Common configs load once immediately after client setup and before title,
connection, input, render or ordinary tick callbacks. This includes pack-error recovery: the failed
selection is cleared and saved during initialization, but its options screen opens only after Config
completes. A title-screen callback is already in World: world-handle constructors can return None,
rather than being forbidden in Setup. A dedicated
server loads Common once before its first world; Server configs load and unload per world.
A second world does not repeat registration or client setup. Config loading can read settings and
use the existing scoped ConfigFiles service to read or save its own config files. World handles,
transactions and other game operations panic with Config in the diagnostic.
Config file polling is not a phase transition, and Server config unloading leaves the World phase.
Every data or asset reload enters Reloading, including listener registration, prepare, apply and
the reload completion notice. Initial loads can nest in Setup; runtime reloads nest in World.
Returning, including after a failed prepare, restores the entering phase. Published asset/data
queries are classified in the catalog (resource_read), not granted by helper-name exceptions.
They read the currently published generation, not pending prepare output: completion callbacks can
refresh sprite and recipe caches after apply, including on first world open, without gaining world
handles or transactions. Their rustdoc names their load-phase availability. Server listener
registration retains &dyn Host, restricted to these published facts, registry facts, settings and
mod-list queries; callbacks already taking DataLoadContext keep that context. Neither permits
opening a world handle or transaction. The first client registration and prepare run on the loader
thread; later registration runs on the render thread, prepare on its worker, and apply on the owning
game thread. Startup's empty client Host is not an extra permission.
DataLoadOrigin and LoopSide are API-owned enums, not engine exports. Load and loop
callbacks read frozen registry and settings metadata through DataLoadContext and LoopContext.
The engine supplies borrowed LoadReader and LoopReader implementations; the API's owning
views contain no engine types and do not copy or rescan the resource snapshot. Mod listeners
keep using the context traits, with the same origin, side and registry ordering on both targets.
The reader adapters themselves are native host implementation details, not wasm resources.
The game alone advances phases. An illegal or wrong-thread advance is refused before dispatch;
a forbidden mod call panics naming the phase, rather than returning a Result a listener could ignore.
There is no separate common-setup, load-complete or intermod event: registration and the first server
reload cover common setup, client setup is client_setup, dedicated setup is server_about_to_start,
and dependencies express intermod ordering. Load complete is the edge after initial Config and before
server_about_to_start, not a hook.
Settings snapshots accept only regular UTF-8 files, without following file or package-directory symlinks (including internal links). Invalid UTF-8, special files and refused entries are absent, not errors. Each file is limited to 64 KiB before trimming, each mod to 1,024 captured keys, and the whole snapshot to 16 MiB before trimming. Over-limit keys are omitted in sorted package-id and key-name order. Enumeration is limited to 4,096 names per package; packages exceeding that budget are omitted entirely rather than retaining an unstable enumeration prefix. One warning per refusal class is emitted per snapshot. These generous limits cover the small scalar setting values used by the settings API; they do not apply to mod data files.
The generated executable uses the installed game's crates/horizon-client/src/native_main.rs.template.
The launcher supplies only ordered literal mod IDs and hook factory paths. The template owns entry
construction, the allocator and the canonical game identity. Source without this template is refused
before staging trait mods: update the game first.
A mod whose bootstrap cannot complete should call cx.refuse("reason") inside
Hooks::register, then return. This refuses only the calling mod's registration:
there is no mod ID argument. Its declarations from that registration (before or
after the call) are discarded. Later declarations do not add secondary validation
errors, and another mod's declarations are unchanged. Native and wasm report the
same diagnostic through the normal registration error path. This does not panic
or exit the process. The first reason wins, including an empty reason. Reasons
are preserved verbatim up to 4096 UTF-8 bytes; longer strings are truncated at the
last character boundary within that limit.
A panic in a trait mod's hook adds in native mod <id>, hook <hook> to the panic's message, in the crash report
and in the server thread's log line, with the horizon-mod.toml id. Each entry passed to
entry::main_with must bind its id and hooks together: ModEntry::new("example", example::hooks()).
The ordered list is Mods = Arc<[ModEntry]>; there is no separate naming step or unnamed entry.
Before installation, mod_api::mods() still returns an empty list. Parts retain their registering mod's
id for startup errors and hook panics. The game stops. A point listener's panic names its mod and
method; render hooks are not named yet.
A native runtime that hosts converted packages (Portizon's wasm packages, a Java interpreter's jars)
declares the packages it loaded with mod_api::content::crash::ConvertedPackages::new(runtime_id, ids) and
marks a guest call with mod_api::content::crash::ConvertedPackage::enter(export, site). The scope nests,
restores the previous one on return and while a panic unwinds, and adds in converted package <id>, export <export>, call site <site> below the runtime's in native mod line in the report. A package
id the runtime did not declare is refused, as is a scope entered while another mod's call is the
current one, so guest code cannot impersonate a mod the runtime did not load.
The guard is thread-affine (not Send or Sync), and attribution ends when the native frame that entered it exits, even if the converted guard is retained. A nested native frame hides the outer frame's package until it returns. Worker threads have no scope unless the runtime enters a native call and package scope there. Registry ids are stored, not interned: entering a package clones an owned id on every call (borrowed static ids do not allocate).
A code mod may listen at the game's injection points as a rare escape hatch, through a crate-root function
named by [[inject]].with, taking a mod_points record; the form
and its limits are in Injections.
A linked trait mod implements mod_api::Hooks. A hook function is not an injection point, so
[[inject]] never runs inside a hook's dispatch; a head injection that cancels the game function
calling a hook keeps the hook from firing at all. A claimed cancellation reaches only trait listeners that receive cancelled events.
Otherwise, trait mods run by priority, then in dependency order. Once a mod cancels, later mods are skipped unless
they receive cancelled events. For replacement hooks, the first Some wins. Collection hooks call every mod.
Receiverless hooks have no Host.
To save from inside a hook, use level.request_save(host) so the save runs at the next settled
start-of-tick boundary. A synchronous save-all while an entity pass or held-out row is borrowed
fails with vanilla's commands.save.failed feedback and a failed command result, without saving.
To emit a vibration-producing game event, use level.game_event(host, event, at, context).
GameEventContext carries an optional source entity and affected block state; see
game events with context for listener semantics and refusals.
#[listener] on a hook method sets both, as NeoForge's EventPriority and receiveCanceled do. Two mods, each
its own crate:
#![allow(unused)] fn main() { #[mod_api::horizon_mod] impl Hooks for Protection { // Runs before mods at the default priority, 0; higher runs first. #[listener(priority = 100)] fn break_block(&self, host: &mut dyn ServerHost, event: &mut BreakBlockEvent<'_>) -> Flow { if self.protected(host, event.pos) { Flow::Cancel } else { Flow::Continue } } } #[mod_api::horizon_mod] impl Hooks for Override { // Still told after a cancel, and may undo it. #[listener(priority = -100, receive_cancelled)] fn break_block(&self, host: &mut dyn ServerHost, event: &mut BreakBlockEvent<'_>) -> Flow { if event.cancelled && self.allowed(host, event.pos) { event.cancelled = false; } Flow::Continue } } }
priority is an i32, 0 when omitted; mods of equal priority keep dependency order, as [[inject]]
priorities do (Injections). Each hook's order is fixed once, when the mods are
installed. Every cancellable hook's event has a cancelled field: a mod without receive_cancelled always sees
false, since it is not called once the event is cancelled. A mod that receives cancelled events and sets it back to
false lets the game's behaviour run, and the mods after it are told again. An event the game hands over already
cancelled reaches only mods that receive cancelled events. receive_cancelled on a hook without a cancel does not
compile, and #[listener] is for a trait mod's hooks only: a part is called for its own thing, in no order.
Native and wasm trait mods use the same listener ordering and cancellation rules. Wasm subscription records carry the hook name, signed priority, and receive-cancelled flag through the shared-memory Wire boundary at load time. The event's cancelled field crosses with its other writable fields. No component-model adapter is involved.
Each hook method takes its host, where it has one, and one event:
#![allow(unused)] fn main() { fn living_incoming_damage( &self, _level: &mut dyn LevelRandomHost, event: &mut LivingIncomingDamageEvent<'_>, ) -> Flow { if event.kind == "minecraft:pig" { let halved = event.container.new_damage() * 0.5; event.container.set_new_damage(halved); } Flow::Continue } }
The event is a generated struct named after the catalog hook, in CamelCase plus Event, with
the hook point's arguments as public fields: values as values and borrowed ones as references (event.kind). A
number or bool a mod may change is a plain field, written back once every mod has run, even after a Cancel;
anything else a mod may change, a game struct or a list, is a &mut field. A cancellation or replacement is still
the return value; a collection hook's list is a field (event.lines). One event goes through every mod, so each
sees the changes before it. Because no mod can build or match the whole
struct, a later version can add a field without breaking any mod, as NeoForge's event objects do; the cost is that
a mod's own tests drive a hook through its dispatch function (Testing a trait mod).
Hook methods take &self and require Send + Sync. The process-wide mod list is installed once, like mixin module statics. One mod instance serves both
the window's game and the server-thread game. A hook can also fire again inside
the same mod: for example, LevelRef::set_block fires block_changed. Mods keep mutable
state with interior mutability so these calls can share the instance safely. Data-load
hooks can run on any loading thread; item default changes must be identical on every side.
The trait has every hook declared under crates/horizon-mod-vocab/src/hooks/ (Adding a hook).
Level hooks use access-specific hosts rather than Host. LevelHost provides
block_state through LevelAccess::block_state, client-side detection and
state_with_property. state_string(state) serializes the full name and properties, or returns None
for an unknown state; min_y() and height() read the level's build range. It also reads light and loaded state, through the game's own queries:
brightness(LightLayer::Block | Sky, pos) (getBrightness, the stored 0 to 15),
raw_brightness(pos, sky_darken) (getRawBrightness, which CropBlock.randomTick asks with 0)
and is_loaded(pos) (Level.isLoaded: inside the build height and in a loaded chunk),
is_raining_at(pos) (Level.isRainingAt, false where pos is not loaded) and enabled_features()
(LevelReader.enabledFeatures, a borrowed FeatureFlags<'_>). Neither allocates.
None of them loads a chunk. Light above the build height reads as full sky and below it as
none, and a chunk that isn't loaded reads as the game's query does (full sky, no block light),
so check is_loaded first. It also answers tag and fluid questions without a Host: block_in_tag(block_key, tag),
fluid_state(pos) (getFluidState, None where the block read is),
state_fluid_state(state) and fluid_in_tag(fluid_name, tag). Fluid reads return FluidState with a
FluidKey, amount, falling flag and engine-derived source flag; is_empty() and is_source() report
those states without a registry lookup. block_state_id(name) parses a block and properties, and block_state_default(block) gives a block's default state, as on Host; both return None for invalid input or unavailable context. Tag reads are None when authoritative tags are unavailable, including on a client before its server's tags are applied. For item membership and member lists, use Host::tags(): resolve a TagKey with Tags::key, then call Tags::is_in_tag, Tags::items or Tags::fluids. Membership reads return None on clients until server-sent tags are applied, which tags_updated announces; they never substitute local resource-pack tags.
LevelHost has no random, by design: a draw from a read-only hook inside a level write would shift the level's random sequence away from vanilla's order. A hook that may draw is given a LevelRandomHost.
LevelRandomHost provides vanilla's Level.random so
mod draws come in vanilla's order. LevelTicksHost adds scheduling through
native_schedule_block_tick and native_schedule_fluid_tick, while
LevelWriteHost adds random access, set_block, set_block_property, native_play_sound and
native_send_particles and native_level_event (levelEvent, queued in order with the write's own events).
set_block(pos, state, flags) is Level.setBlock on a whole state, as LevelRef::set_block takes it: it
removes the old block's block entity and creates the new block's in the write (LevelChunk.setBlockState), runs
the neighbour and shape updates flags ask for, and queues the write's level events in order. A mod block entity
it creates starts with the data the block_entity hook gives, which is fired in the write and receives the level
(LevelSide, read-only: vanilla's newBlockEntity gets no level), as the Host's native_set_block fires it. It returns true when the block
changed, and false off an authoritative server, for a position that is not loaded or outside the build height,
an invalid state, or the state the block already has. It never loads a chunk. The game owns
the tick queue; mods do not pass it back to these helpers.
Frame drawing through DrawContext, BlockEntityDrawContext and WorldDrawContext stays
in mod_api. Raw GPU access is native-only, unstable and outside that API, in
mod_render::render (horizon-mod-render). Register its listed frame functions through
[[inject]] with access = "internals" and kind = "head"; the ordinary listener registry
owns ordering and disabling. render_needs collects requests; world_drawn runs before
the hand and HUD, and hand_drawn runs after the hand, on the render thread.
| Removed path or registration | Current route |
|---|---|
mod_api::render raw GPU types | mod_render::render (native escape hatch) |
RenderHooks / Hooks::render | [[inject]] internals listeners on the three listed frame functions |
| API draw contexts | Unchanged; use these for API-typed drawing |
The typed API from mixin code
An [[inject]] listener can use the typed API: an API point's listener gets the side's Host and calls
attachments and handles through it. The worked examples, and which shape to choose, are in
Injections; each mixin injector's form is in
From mixins and legacy kinds.
A catalog hook's trait method, event and return make up its hook_version contract
(Named hook points). These per-hook contract versions are catalog metadata, not minimum game
versions. Catalog hooks, Host calls and internals [[inject]] declarations all build against the
game's exact source revision. Rebuild the mod when that contract changes.
Every impl Hooks must have #[mod_api::horizon_mod]. The attribute derives
subscriptions from the methods implemented in that block, so dispatch knows them at installation.
Parts
A part is code a trait mod attaches to one block, block entity, item or mod menu type, its own or vanilla's
(the spec). A mod attaches its parts once, at startup, in
Hooks::register. Each part trait implementation must have #[mod_api::horizon_part]; the attribute
subscribes it only to the methods it implements. A BlockEntityPart also needs a separately annotated
BlockPart implementation:
#![allow(unused)] fn main() { struct Gauge; #[mod_api::horizon_part] impl BlockPart for Gauge { fn has_analog_output(&self, _: &dyn LevelHost, event: &mut HasAnalogOutputEvent) { event.output = true; } fn analog_output(&self, _: &dyn LevelHost, event: &mut AnalogOutputEvent) { event.output = 15; } } #[mod_api::horizon_mod] impl Hooks for MyMod { fn register(&self, r: &mut dyn Registrar, _: &RegistrationContext) { let stone = r.block_key("minecraft:stone"); r.block_part(stone, Gauge); } } }
On r: &mut dyn Registrar, r.block_key(id) and r.effect_key(id) request registry entries,
not new content. Registrar is the generated union of the kind-local registration traits
(such as BlocksRegistrar and EffectsRegistrar); mod registration hooks and the
horizon-machine-sample continue to
use dyn Registrar. Each kind owns its declarations and implementation in parts/<kind>.rs;
the union and WebAssembly bindings discover those traits without a shared method list.
The capability-provider lookup union is assembled the same way. Declare them in register, retain the returned BlockKey or EffectKey in the
mod or Part, and reuse them for registry operations. Requests are canonicalized (a bare id
uses minecraft:), deduplicated across mods and checked after every mod's content is known.
An unknown entry refuses the load with the requesting mod and canonical id, even if the
key was never attached to a Part. A later mod may supply the requested entry.
The completed block identity table includes every vanilla and mod block, even blocks no mod requested. Completion preserves requested slots and appends the remaining canonical names in sorted order. Names belong to the installed mod registry; world load and reload prepare a separate numeric mapping for every retained key before publishing it. A missing requested block refuses completion without publishing a partial table. Immutable block definitions (property names, legal values, state combinations and defaults) are published with those names before mesh tables are constructed. Workers share read-only definitions; property reads allocate nothing. Pack reload does not mutate definitions or identities. The world's numeric mappings and tag membership remain separate reloadable data.
Metadata contexts are read-only and borrow the same table on the host and on workers. Wire transport records are internal, not saved block values. Persist canonical identifiers and property strings rather than slots or state ordinals.
BlockState is a small owned value with private fields, not a tint-specific snapshot or an
engine numeric ID. state.block() reads its BlockKey; state.ordinal() reads its local
ordinal. Property operations take explicit metadata: retain let metadata = host.block_states()
and call state.property(&metadata, "facing") or state.with(&metadata, "facing", "east").
BlockState::of(&metadata, key, constraints) starts from the published default and applies the
supplied StateConstraints. Changes return a new value and never mutate the original.
Unknown keys, ordinals and property combinations return typed BlockStateErrors.
SceneBlock::new(pos, state, entity) and StructureTemplateBlock::new(pos, state, nbt)
use this owned BlockState, including all properties. Structure reads preserve vanilla palette
selection, mirror-then-rotation and placement order; an unavailable state mapping refuses the
whole snapshot with None. DrawContext::draw_scene checks the entire scene before drawing:
an unknown key, invalid ordinal or unavailable mapping draws nothing, never a partial scene or
a replacement air block. Native and wasm use the same values and refusal rules.
Tint callbacks receive the same immutable metadata resource explicitly, without a Host.
The game prepares their owned state array before constructing mesh tables; renderer workers
only copy those values and invoke metadata reads. When world numeric state IDs are reordered,
the game resolves each state's property combination against the published definition, so
local ordinals retain their meaning across reloads.
host.block_states().states_of(key) returns a BlockStateRange in that same immutable
local ordinal order. Its iterator yields owned BlockStates; it is not a span of world
numeric IDs. BlockStateRange::new(first, count) returns a typed error if the span would
exceed the u16 ordinal domain, and decoded transport spans obey the same check. Membership
checks both the block key and the ordinal. A zero-count span is empty.
A restricted LevelHost::block_state(pos) read returns Option<BlockState>. A successful
engine read retains its vanilla result: unloaded or out-of-height reads that produce air return
Some(air), using the owning registry's real air state. An unavailable owning state index or a
refused engine-to-API conversion returns None; the boundary never fabricates a key or air state
and never panics to satisfy a mandatory value. Other optional/result read contracts are unchanged;
in particular, this does not change LevelRef's loaded-position policy.
LevelRef::set_block, update_neighbors and neighbor_changed take owned block states
and return Result<bool, LevelWriteError>. Stale identifies a closed or reopened level;
Unavailable identifies the wrong side, phase or nonactive write target; InvalidState
retains the block-state refusal. Errors do not mutate the level. Ok(false) is still
vanilla refusal, such as an unloaded or out-of-height position or an unchanged block,
not a lost handle or a guessed replacement state. Update flags retain their vanilla effect.
Walking a whole registry uses the typed keys, never names: host.block_keys() and
host.item_keys() return every entry in the registry's registration order (vanilla's
entries first, then mods' in the order they registered); host.entity_type_keys(),
host.menu_keys() do the same for the built-in entity-type and menu registries.
host.enchantment_keys() uses global sorted loaded-registry order, with vanilla and mod
data-pack entries sorted together. A block registry lists a name once
per state, and its iteration lists the block once. Build lookup tables from these lists at
load time instead of resolving names per tick. A table is empty before its registry loads
(blocks and items during rest load, enchantments with the item tables); registration-time
RegistrationContext does not see any of them (#1001).
Keys are small, private u32 registration slots on both native and wasm. key.id(host)
returns an owned canonical String through the generated read backend for diagnostics; it
requires a game-backed host and an invalid slot is a boundary error, not an empty name.
Block defaults come from immutable API metadata and cross through the game's sole forward/reverse
state index. Behavior queries obtain their engine block from the resulting engine state; there is
no separate per-key numeric block binding. Reload preserves key identity and canonical names.
A refused resource rebind clears the active index, while failed transactional Parts preparation
leaves the previously published Parts and index intact. Effect registration is process-owned and fixed
until restart, matching the engine's effect registry.
A name that only arrives at run time (data, config, a command, a network message) turns into a key
with the host: host.block_key("minecraft:stone"), host.effect_key("mymod:rage") and
host.fluid_key("minecraft:water") return Option, and key.id(host) gives the name back for
blocks, effects, fluids and items. Names are full registry names; the lookup reads the completed
registry and does not allocate, so a hook can call it. It returns None for a name the registry does
not hold, and before the registry is complete, so a mod uses r.block_key(id) in register. These
work on the client and the server, for native and wasm mods alike.
Block tag membership is bound from the same merged item/block tag snapshot at world load and
reload, including worlds without pack overrides. A read checks that the owning state index is
available, looks up the tag name and tests membership in a prepared API BlockKey set; it never
resolves the block's name. A fully namespaced tag needs no allocation (a bare tag adds minecraft:).
Binding visits each merged block-tag member once and resolves its API key; it replaces
the previous snapshot rather than accumulating membership. No membership work is added to ticks
that do not call this read. Tags and block mappings belong to their resolving thread; missing
authoritative context or an unavailable mapping returns None, not a stale membership result.
host.tags().blocks(tag) reads the current authoritative block members in vanilla order as
BlockKey values, including blocks no mod requested. Resolve tag once with Tags::key and
retain it across reloads. Unknown or empty tags return Some(Vec::new()); an unavailable
context or any unresolved member returns None, never a partial member list. The same service
is available through restricted level Hosts, without borrowing an engine registry.
host.block_state_default(stone), host.block_states().states_of(stone),
host.block_item(stone), host.block_in_tag(stone, tag) and restricted-level
schedule_block_tick(pos, stone, delay) use BlockKey. Property expressions accepted by
block_state_id, tag names and existing fluid operation arguments remain strings for now.
Declare a tank-only fluid in your own namespace with r.declare_fluid("chocolate"); the returned FluidKey identifies it for the mod load. Request vanilla or another mod's declared fluid with r.fluid_key("minecraft:water") or r.fluid_key("other:chocolate"). Requests resolve after all registrations, so declaration order does not matter; an unknown request stops the load and names the requesting mod. Tank-only fluids have no world simulation or world state. Fluid stacks and recipe ingredients use these keys; remaining fluid operations move to them in subsequent changes.
Declare a fluid that exists in the world with let mut fluid = WorldFluid::default(); fluid.drop_off = 2; r.declare_world_fluid("oil", fluid);,
using mod_api::WorldFluid, the code form of a fluid file's world object (content mods): it adds the block
mymod:oil, the flowing fluid mymod:flowing_oil and the bucket mymod:oil_bucket, and its fluid file, if it has one,
still says how it looks. r.fluid_part("mymod:oil", Part) attaches a FluidPart, whose fluid_rules can change the
declared values by position on the server. It is asked for every cell a spread looks at, so answer fast.
A saved tank whose fluid's mod is absent carries an opaque unresolved identity. Its stack's
key() is None; UnresolvedFluid::id(host) reads its saved name. It can move between
name-backed mod tanks, but not into vanilla buckets or cauldrons.
ActiveEffect::id and effect hook payloads carry EffectKey; living effect queries,
application and removal use the same resolved key. ActiveEffect::new(speed, …)
uses a key requested through r.effect_key("minecraft:speed"). Beacon-selection hooks
carry Option<EffectKey> for each power; None is no selection. Enchantment effect-component
names (for example minecraft:damage in has_enchantment_effect) are a different registry
and remain strings, as do labels and tags.
Hot item and block identities
Resolve item and block keys during load and keep them for tick and menu calls.
item_burn_time, item_compost_chance, give, enchantment_supports's item,
and GUI item / item_model take ItemKey. block_item, fluid_bucket and
screen_hovered_item return item keys. level.block, lit_block_name and
Hit.block return block keys; BlockEntityDrawContext::block takes one.
HeldItemSlot.id (including use-start/stop events) and Stack.item
carry item keys, not names. Unknown incoming slots are refused at the boundary.
block_sprites takes API-owned BlockState values, preserving properties without
parsing state strings or exposing numeric engine state IDs. The pre-change block event carries resolved from / to
states and does not allocate their names. Property names and values in
set_block_property are not block identifiers and remain strings.
These calls remove string transport across wasm. Engine metadata reads may still hash borrowed registry names; no formatted identifier is built for these inputs.
Matched recipe lookup
level.recipe_for(host, &recipe_type, &inputs) replaces the crafting-only lookup.
Use RecipeInput::Crafting { width, height, slots } for minecraft:crafting
(row-major slots, dimensions 1 through 3), Single(stack) for smelting, blasting,
smoking, campfire cooking and stonecutting, or Smithing { template, base, addition }
for minecraft:smithing. Recipe types are not serializers: shaped and shapeless
files both belong to minecraft:crafting, and transform/trim both belong to smithing.
The server uses the same engine matchers and assembled outputs as its menus,
including data-pack overrides of vanilla recipe types. A RecipeMatch contains the
matched recipe identifier, typed output, ordered remaining items, experience and
processing time. Lookup consumes nothing; the caller applies inventory changes
transactionally. Keep the result until inputs or the recipe load change.
Ok(None) means valid input without a match. Invalid shapes/items and unavailable
levels are typed errors. A type without an engine matcher returns
RecipeLookupError::NotMatchable, not an empty match. For mod-owned types, retain
recipes_of_type once per load/reload and match that cached data inside the mod,
with zero crossings per tick. Dynamic custom outputs and contextual predicates
remain mod-owned; the host does not call guest code to match recipes.
At publication, call ingredient.item_keys(host) once per ingredient to retain
Arc<[ItemKey]> for local matching. This load-time read returns exactly the plain
item membership for lists, expanded tags and standard shapes, using the engine
matcher. It returns IngredientKeysError::NotEnumerable if any custom or component
predicate occurs anywhere in the tree; a displayed item list is not a safe substitute.
UnknownItem refuses a missing registry identity. Do not call this method per tick.
The sample's ProcessingRecipes::publish retains these keys; result then checks
input.item() locally, with no Ingredient::test host call per candidate.
Stack placement predicates and drawing consumers (HUD items, GUI item decorations, block-entity items and tooltip images) encode API stacks without enforcing resident count or damage maxima. Engine-legal over-limit stacks can still be inspected and drawn. Unknown item keys and invalid/unrepresentable effect payloads still refuse encoding; this is not a total conversion. Insertion and other world writes retain strict validation.
API-owned values: migration table
| Previous engine value | API-owned value | Mod changes |
|---|---|---|
Engine stack in TooltipDisplay::for_stack | mod_api::ItemStack | Pass the owned API stack supplied to the tooltip event. Its effective tooltip component includes vanilla's implicit defaults. Normal game rendering borrows the engine component without creating an API stack. |
| Engine loot context and candidate stack | mod_api::{LootContext, LootStack} | Contexts own their facts; candidate items are ItemKeys. Counts remain signed. Ordered Option<Tag> patches preserve removals and NBT types, and names are API Text trees. Writeback is atomic. |
Engine ParticleType and options | ParticleKey and API ParticleOptions | Retain a key requested through Registrar::particle_key. Every parameter-family constructor takes its key; simple(key) also replaces string-based custom provider construction. The engine enum is no longer exported. |
| Engine damage containers | mod_api::{DamageContainer, DamageValues, Reduction} | Hooks receive an owned scalar snapshot. Read reductions with reduction; edit damage and cooldown through the container setters. Sequencing methods remain game-only. |
| Engine item property values | mod_api::{ItemBar, ItemRarity} | Resource methods retain their signatures; damageability, glint and repair checks use game-owned metadata reads. |
Package manifest Side | mod_api::Side | Match Client and Both on the package metadata snapshot. This is not HostSide or the executing logical side. |
Command Suggestion | mod_api::Suggestion | Construct with Suggestion::new((start, end), text); optional tooltip and int_value remain writable. Offsets are byte offsets. |
| Package block and item declarations | mod_api::{Block, Item} | Use API-owned declaration records in the registration events. Component patches are ordered (String, Option<Tag>) entries, with None removing the component; the game converts once at load and applies content-file admission checks. |
| Package block entity type storage | mod_api::BlockEntityTypeDecl | Existing r.block_entity_type(...).build() registration is unchanged. The declaration snapshot no longer exposes the package parser's model; the game converts it once when declarations freeze. |
| Component protocol vocabulary | text::{TextContent, TextNbtSource, TextObject, TextClick, TextHover} | Match these enums exhaustively: their serialized vocabulary is closed. Records remain non_exhaustive. |
| Renderer GUI commands | mod_api::GuiDraw | DrawContext::gui_paint returns a typed text-conversion result; handle refusal before drawing. HUD hooks reject invalid writeback as a whole. |
| File-opening click actions | Not in text::TextClick | Mods can neither produce nor round-trip OpenFile; untrusted file-opening action metadata is dropped. Core tracks local/remote origin per node, including mixed trees; exporting JSON or TextForm and decoding it makes every node remote. Adding API OpenFile remains a separate vocabulary/link-contract change. |
mod_api::GameRuleValue owns the Bool(bool) and Int(i32) cases, in that order.
level.game_rule(host, id) returns Result<Option<GameRuleValue>, LevelReadError>:
Ok(None) means unknown rule, Stale means a gone/reopened level, and Unavailable
means the side or phase cannot read it. level.game_rules(host) returns the typed
result containing every available rule, not an optional list. The existing boolean
key getter is unchanged. The game converts values after the subscription guard for
game_rule_changed; no subscriber means no conversion work.
The API's GameRuleDecl records a mod's boolean rule name, category and default.
Its internal RuleSchema input describes vanilla categories and dense rule positions
through read-only lookups rather than a copied vanilla list or fixed slot numbers.
Schema validation resolves category references after all mods have declared them and
returns either all declaration slots or every refusal; it never installs a partial table.
The game supplies this schema at Parts installation, then builds each world's engine
rule registry from the installed GameRuleDecl snapshots. Existing rule registrar calls
are unchanged; engine registry construction is no longer exposed through the API.
The game owns package and completion conversion. Mods do not import package-loader or client-core types for these values. Completion edits are copied back after subscribed listeners run; no listener means no snapshot allocation. Package metadata is copied once at startup, retaining declared namespaces and dependencies.
Opaque NBT metadata
The shared JSON/NBT conversion is iterative and follows vanilla's 512 nested
list/compound limit; scalars and NBT typed arrays add no recursive container.
Conversion to JSON intentionally loses numeric tag widths, boolean-as-byte
identity and typed-array identity. NonFinite refuses NaN and infinities:
serde_json::Value cannot hold them, and Gson's strict JSON writer also refuses
them. InvalidString refuses an unpaired UTF-16 surrogate in a Tag string or
compound key. Neither case is replaced with null, a string or U+FFFD.
JSON input already uses UTF-8, so valid non-BMP pairs survive; a lone surrogate
cannot reach this converter through that input representation.
Owned component trees
The text module defines owned component data: literal and translatable contents,
style overrides, ordered children, selector and NBT sources, inline objects, and click
and hover actions. Translation keys and argument components stay unresolved until the
game displays them. None style fields inherit; Some(false) overrides an inherited
decoration. Metadata, dialog/custom click payloads and hover-item component patches are
owned Tag data, never JSON strings or engine state. TextProfile carries an optional
name and UUID, signed property records and opaque unknown profile/property fields.
Signed record order and unsigned values within each property name are preserved.
Player names use U+0021..U+007E and at most 16 characters. Vanilla's unsigned
property-map form allows at most 16 distinct names; signed or metadata-bearing
records use a 16-entry list, with UTF-16 limits of 64, 32767 and 1024 units for
property name, value and signature respectively.
Inline player and atlas objects use Option<Box<Text>> for their fallback: None
keeps the engine-computed default; Some preserves a styled or translatable component
rather than flattening it to a string.
The native boundary plans borrowed source topology before cloning payloads. It reads
source-less spans directly and validates every Tag's JSON representation before building
an engine component. Text::validate() checks tree/payload bounds and profile fields
without cloning; validate_depth() is the component-only preflight. NaN/infinity and
unpaired UTF-16 remain legal Tag data but cause typed NonFinite or InvalidString
refusals when engine emission requires JSON. Nested JSON null is refused as NestedNull
when capturing opaque NBT fields; it is never silently replaced.
Host methods accepting or returning Text return Result<_, TextFormError>; mixed
item/Text writes preserve the refusal in StackWriteError::Text, and unspawned-mob
reads use UnspawnedMobError::Text. TooDeep means a component path or opaque NBT
container path exceeds 512. Unavailable backends are distinct from invalid text or
unrepresentable payloads.
A refused hook capture does not dispatch; a refused writeback leaves every field unchanged.
No subscriber means no conversion or snapshot allocation.
Mutable stack arguments are compared using value equality with the snapshot passed to
the listener. An unchanged argument retains the original engine value without strict
write validation (including engine-legal stacks above effective count or damage maxima).
A changed argument is validated in full; a refusal still commits no outputs. The same
rule applies to anvil/grindstone records and menu cursor/quick-move stacks.
Recursive Clone, Eq and Drop of a tree a mod itself built are ordinary Rust ownership,
not part of the boundary guarantee.
Text::new(content) constructs any content kind without exposing an engine value.
The content, NBT-source, object, click and hover enums are exhaustive protocol enums;
new cases change the link contract, just as enum-case order does. Mod matches should
name every case rather than silently treating a new protocol case as an old one.
Typed content and style fields are authoritative; metadata retains unrecognized
properties, not overrides of those typed fields. Game-side encoding is fallible
and reuses the engine's translation-argument and fallback construction rules.
Capture keeps dynamic contents and metadata rather than flattening the rendered spans.
It shares the engine's SNBT flag, shadow-colour, click-policy and inline-object parsers.
File-opening metadata is discarded before a listener receives the owned value; a
no-op listener cannot promote an untrusted file action into a local action.
A translatable's fallback is a literal format template, not another language key.
A missing key whose fallback equals an existing key therefore still displays the
fallback text itself; parsing and native constructors follow the same rule.
Particle identities
ParticleKey is an opaque slot in the load-owned registry tables. One completed
snapshot retains both canonical names and provider identities: a vanilla provider's
numeric index or a custom named sprite provider. The load resolves each provider once;
reads of a retained key do not parse identifiers or scan the provider list. Unknown
forward references refuse publication of the entire particle snapshot. Name-table
completion shares the item registry's requested-slot preservation and sorted enumeration.
Placement and loot snapshots
mod_api::PlacementContext owns the placement inputs, including world-space click
coordinates and the placer view/flags. Its item is Option<ItemKey>: None is a
real empty hand, including falling-block placement contexts, not an invented air
item. Nonempty items resolve through the existing load-owned item table. The game
captures this read-only snapshot after the scoped subscription guard; no subscriber
means no snapshot or item lookup. The block-state answer is applied as before.
The snapshots module owns placement inputs and loot-roll facts. Placement and tool
items are resolved ItemKeys. Loot context entities, damage sources, tools, biome,
block and place facts are owned values; retaining one never retains a borrowed engine
context or a live level. A loot candidate is not an inventory ItemStack: its signed
count can be non-positive while loot functions run, in which case it produces no item.
Exploration searches remain deferred data, and name components remain unresolved text.
Item values and tag names
Every item has an ItemKey, including vanilla items no mod requested during registration.
r.item_key(id) retains a forward reference in the same load-owned table; after the item
registry is complete, host.item_key(&identifier) resolves any registered item. A key is
still a private u32 index, not a copy of an item definition. Resolve once and retain it.
host.max_stack_size(key) and host.max_damage(key) read item defaults without exposing
the engine's item table.
ItemStack is owned API data with private fields. ItemStack::new(key, count) refuses
zero, while ItemStack::of(key) makes one item. Count mutation refuses zero and arithmetic
overflow. Registry-dependent maximum count and damage, including component overrides,
are checked by the game when accepting a write; an oversized value is refused with a typed
error, never silently clamped. This is the same boundary on native and wasm. Consuming
split(n) returns StackSplit { taken, remainder }, both optional; consuming shrink(n)
returns the remainder or None. There is no empty-stack sentinel. Component descriptors
read and write an optional owned NBT patch; a plain stack has no patch allocation.
host.tags() and a restricted level host's tags() expose the same Tags service. Resolve
a validated identifier once with tags.key(&identifier) and retain its TagKey. The runtime
owns tag-name slots for the process; reload replaces membership, not names or key identity.
tags.is_in_tag(item_key, tag_key) tests merged item membership. tags.items(tag_key)
and tags.fluids(tag_key) return registered keys in vanilla's member order. Unknown tags
return false or empty lists. Membership reads return None on clients, before authoritative
tags load, or if the relevant registry mapping is unavailable. Key resolution itself can be
refused if the runtime cannot allocate a name slot. No lookup stringifies a retained key.
Block member enumeration moves with the registry-wide BlockKey table in the next type slice.
The engine TagRegistry, StackData, StackView and DisplayContext are not API types.
A hook whose catalog entry has a scope (block, block_entity, item, menu) and a key, the argument
naming the thing it is about, is a method on that scope's trait only: BlockPart, BlockEntityPart (a
BlockPart too), ItemPart and MenuPart, as vanilla's and NeoForge's Block and Item methods are. It has
no Hooks method: a thing without parts is never asked, and the game asks the parts attached to it in dispatch
order, through the hook's contract, so a Cancel or a replacement skips the parts after it. Where a NeoForge
bus event is the counterpart for that moment, the event is a hook of its own on Hooks, asked first for every thing, and its cancel
skips the parts: a mod reacting to its own blocks uses a part, one reacting to every block the event. A part's kind is its type: the same kind twice on one
thing, an invalid key declaration, or an unnamespaced id in the other Part registrations stops
the game at startup with a message naming the mods. A panic in a part
adds , part <type> to the mod and hook it names. Parts take &self and are Send + Sync, like Hooks.
Damage and item metadata snapshots
The three damage hooks convert only after their subscription guard. DamageValues
retains the entire sequence, including reductions and the pre-hook input, so changing
new_damage still reaches the engine's original NaN and negative normalization point.
DamageValues::set_reduction edits snapshot data; it does not run a damage calculation.
The fixed reduction storage is private; use its accessors rather than an array index.
Reduction case order is append-only. Engine record_*, enter_pre, capture_inflicted
and pre_change are not mod API methods.
ItemResource::damageable, foil and repairable delegate to the game's existing
property readers through metadata access. Defaults, explicit overrides, component
removals, compass glint and repair tags follow gameplay's single property path.
Recipe-file fields and small values
Host::recipe_ingredient and Host::recipe_result take the field's JSON text,
not a serde_json::Value. Pass a quoted item id (r#""iron_ingot""#), a quoted
#tag, or an ingredient array. Parse failures return RecipeJsonError::InvalidJson;
unknown items and empty tags still return Ok(None). Result objects retain their
components patch. These helpers are reload-time reads, not per-tick JSON parsers.
| Former engine value | API-owned value |
|---|---|
mod_list::KeyConflictContext | mod_api::KeyConflictContext |
firework::FireworkShape | mod_api::FireworkShape |
manifest::JarInputsError | mod_api::JarInputsError, with owned diagnostics and original-jar identity |
Recipe helper &serde_json::Value | JSON &str, returning Result<Option<_>, RecipeJsonError> |
BlockPart::clone_item_stack answers on the server when a player picks the block. It receives the
level, position, block state and include_data, true when a creative player picks with Ctrl. Return
None to keep the game's item.
BlockPart::fall_on runs at vanilla's landing moment on a read-only level random host.
Request a deferred write with event.request(LandingWrite::SetBlock(state)), where state
is an API BlockState, or LandingWrite::DestroyBlock { drop }. The game checks the
complete write batch before queuing it. An invalid block key or ordinal refuses that
callback's writes and other outputs, including damage; it never applies a valid prefix.
After the entity batch ticks, writes apply in request order if the block still matches
its original landed state. That guard runs once, so a first write can change the state
without suppressing the next write. SetBlock notifies neighbors and clients; DestroyBlock
preserves the requested resource-drop flag.
BlockPart::contents_changed(host, event) observes a world block's storage at setChanged,
with a ServerHost, typed event.level, event.pos: BlockPos and event.state. Attach it to the specific
blocks you observe, including vanilla containers; it has no global Hooks counterpart. The backing
contents are already written, before dirty/comparator updates. Native transactions notify once per
backing block at root commit, never on abort or simulation. An explicit BlockEntityRef::set_changed
notifies even if contents are unchanged. A double container notifies each component, not once again
for the other half's comparator refresh. A listener's writes still apply, but a nested notice for that
same backing block does not re-enter the listener; writes to a different backing block notify normally.
Entity/menu placeholder coordinates are not block notices.
This replaces Create's positional contents_changed mixin for depot/package/schematic storage; there is no
NeoForge bus event. A compiled listener that reads the committed items through the typed level (the game's
own test, so it cannot drift):
#[derive(Clone, Debug, PartialEq, Eq)]
struct Notice {
level: LevelRef,
pos: [i32; 3],
amount: u32,
version: Option<i32>,
}
#[derive(Clone, Default)]
struct Changes(Arc<Mutex<Vec<Notice>>>);
#[mod_api::horizon_part]
impl BlockPart for Changes {
fn contents_changed(&self, host: &mut dyn ServerHost, event: &mut ContentsChangedEvent) {
let pos = [event.pos.x, event.pos.y, event.pos.z];
let amount = event
.level
.capability(host, &ITEMS, pos, None)
.map_or(0, |items| {
(0..items.size(host))
.map(|slot| items.amount(host, slot))
.sum()
});
let version = host
.block_entity(event.level, pos)
.and_then(|entity| entity.data(host))
.and_then(|data| match data.get(&"Version".into()) {
Some(Tag::Int(value)) => Some(*value),
_ => None,
});
self.0.lock().unwrap().push(Notice {
level: event.level,
pos,
amount,
version,
});
}
}
Declare a block entity type and its valid blocks during Hooks::register, then attach its part:
let press = r.block_entity_type("press")
.blocks(&["mymod:press", "mymod:big_press"])
.build();
r.block_entity_part(&press, Press).expect("native block entity type");
The builder qualifies the type ID with the registering mod's namespace. It owns the save ID, wire type
and valid blocks; block_entity returns only initial Compound data (or none for an empty compound). It is fired
inside the write that creates the entity (LevelChunk.setBlockState) and receives the read-only level, a LevelSide,
on a server and on a client of one.
Unknown blocks, duplicate types, overlapping membership and attempts to own a minecraft: block are
refused at load. Refused native block, item or block entity declarations stop startup, as does unreadable
known-components input; the game does not fall back to vanilla content. An explicitly declared mod
template block uses its declared type; undeclared template blocks retain their vanilla behavior. Adding mod blocks to vanilla types is not supported by this builder.
The hooks about the entity (block_entity_tick, block_entity_update_tag, pre_remove_side_effects,
menu_button, menu_quick_move) find it by that declared type; the others (block_entity, screen, the
block hooks) by block. The factory is a BlockEntityPart method, not a global Hooks method.
r.block_entity_type_key("other:press") looks up another mod's type, and
r.block_entity_type_key("minecraft:chest") looks up a vanilla type. Both return the same opaque
BlockEntityTypeKey as the declaration builder: equality and hashing use the shared load-owned
registration slot, not the type's blocks. Bare names use the minecraft: namespace. The final
registry resolves requests after all declarations and refuses unknown IDs, naming the requesting mod
and type. key.id(host) reads the canonical name for diagnostics; a key retains its identity until
restart. Use a type ID, not a block ID (minecraft:shulker_box, not minecraft:red_shulker_box). BlockEntityRef::kind(host) returns Option<BlockEntityTypeKey>.
living.hurt_and_break(host, slot, amount) changes equipped durability on the server; negative amounts repair. It returns the pre-shrink stack only on break, and None when nothing breaks or the entity or slot is unavailable. Creative players' equipment is exempt. The operation does not fire equip effects.
block_entity.replace_data(host, data) replaces its saved payload, clearing omitted fields while retaining
identity and position. Like merge_data, it loads transactionally and synchronizes the change; it returns
Err(BlockEntityError::Stale) for an invalidated handle, or Ok(false) for data the entity cannot
load or an unchanged result.
Lookup keys may answer capabilities for vanilla and native types. block_entity_part accepts only
native types and returns BlockEntityPartError::VanillaType immediately for a vanilla key. Native
Parts are expanded after every mod has registered, so a Part can target a type declared later.
Item and tag Parts
For an item-specific Part, request its canonical registry key once in register:
let stick = r.item_key("minecraft:stick"); r.item_part(stick, Sharp);. Bare names use the
minecraft: namespace. Requests may refer to content declared later; the final item registry refuses
unknown IDs with the requesting mod. ItemKey equality uses its load-owned slot, and key.id(host)
reads its canonical diagnostic name. Item capability registration and item helpers still use strings;
their migration is a separate change.
r.block_part_tag("minecraft:logs", Rotates) and r.item_part_tag("minecraft:axes", Sharp) attach a part to
every member of a tag (its name without #). Each game resolves them against its own tags when its world
opens, again on /reload, and when a client joins a server, so the window's game and its server thread each
answer from the tags they play with; a block that leaves the tag is no longer asked. A mod's parts on one
thing run in the order it registered them, by key or by tag. A part a tag gives a thing
that already has that kind of part warns, naming both mods, and the earlier mod's part stays. A tag that
doesn't exist warns. Opening a world fails, naming the mod, when a part is attached to a block or item the game
doesn't have. Scoped hooks run on a game's thread; one dispatched from any other thread (which no game hook
does) can't see tag parts, and warns once.
Instant item use
ItemPart::use_item receives UseItemEvent { stack, hand }. hand: Hand is the
interaction hand of this click (Hand::Main or Hand::Off), including air clicks
with identical stacks in both hands. Read it directly when choosing which held stack
to shrink. living_used_item_hand instead answers only during active item use and
cannot identify an instantaneous click. The Part runs on both sides with HostSide,
after the global right-click event and before the item's own use, on native and wasm.
Return Some(InteractionResult::Success) to consume and swing on the client,
Some(SuccessServer) to consume and swing on the server, or Some(Consume)
to consume without a swing. Some(Fail) skips this item's own use without
swinging and allows the off-hand sequence to proceed; unlike a block-use Fail,
it does not end the entire click.
None (the default) lets later Parts answer; if none answers, the item's own use
runs. Some(Pass) stops dispatch to later Parts and continues directly to the
item's own use.
TryWithEmptyHand belongs to block use and is treated as Pass here.
A thrown item can return Success on the client and Consume on the server.
The hand field corresponds to NeoForge's
PlayerInteractEvent.RightClickItem.getHand().
Hooks::right_click_item runs before Item.use, including after the same hand's
unhandled block or entity interaction. A block success or FAIL, or an entity success, stops fallback.
The server fires it for the separate use-item packet, once for that packet's hand.
Canceling skips the item Part and vanilla action on native and wasm alike.
Item wear
ItemPart::damage_item is IItemExtension.damageItem: the wear a stack of the part's item is about to take
(ItemStack.hurtAndBreak), after Unbreaking, as NeoForge passes it. The part reads event.amount, the points
about to be lost, and changes it (0 or less leaves the item whole) or returns Flow::Cancel for no wear at all. It
runs on every wear path: a player's tools, armour, shield and mace, a tool a dispenser uses, and a mob's armour. It is
not asked when Unbreaking already saved the item or for a creative player's tools, and an item with no part costs
one table lookup. With no part, the vanilla amount applies.
The global item_damaged hook is gone: this method is its one moment, and a part acts only on its own item. What the
global hook could do and a part cannot is change the wear of another mod's or vanilla's items, which no part is
attached to. Do that with an enchantment effect in data (a minecraft:item_damage effect on the enchantment, the
component Unbreaking uses), or with an injection. A part does not get the wearing entity;
host.players().acting_player() names the player on the paths a player takes.
A capability is answered by providers, registered by block entity type as NeoForge's
RegisterCapabilitiesEvent registers them: obtain pipe with the declaration builder or
r.block_entity_type_key("mymod:pipe"), then r.block_entity_capability(&ITEMS, &pipe, |host, at| Some(Box::new(PipeItems))) gives a handler for the block entity and side at (a BlockSide), or none to
leave it to the next provider. A provider may answer for a vanilla block entity type too, using its lookup key.
Providers add up: level.capability(host, &ITEMS, pos, side) (Level.getCapability, on a LevelRef) asks those registered
with r.block_entity_capability_before_game first, then the game's own (a block entity's slots by side, by the
rules hoppers follow, and a mod block entity's block_entity_container), then the others, each in registration
order, and the first to answer wins. That order is the fork's choice, the game first and _before_game to go ahead
of it: NeoForge 26.3's javadoc doesn't state one between mods' providers and the game's. It returns a Capability, the handler and who answered. A handler holds
no borrow: each call is given the host, so it may call a
neighbour's capability, and it keeps its state in the block entity's slots, compound or attachments. A provider
is given the block entity and side, so the handler it makes knows where its contents live. Item handlers are
NeoForge 26.3's ResourceHandler<ItemResource>: indices from 0 (size), what each holds (resource, an
ItemResource: an item and its components, without a count) and how much (amount), how much of a resource an
index holds when it holds that (capacity, none where it may not go), and insert and extract, which take a
&Transaction and answer how much moved. There is no simulate: a write happens in a transaction
(host.transaction(|host, tx| …), below), and to try one, abort it. move_resources(host, &from, &to, filter, max, tx) (NeoForge's ResourceHandlerUtil.move) moves what filter accepts, up to max, index by index out of from
into wherever to takes it, each move all or nothing: what to won't take stays put. It answers how much moved, or
Overanswered (below). Hoppers use a mod's handler
where one is registered, each move in a transaction of its own. A mod's own capability is a
CapabilityKey::new("mymod:chemicals"), answered only by parts.
A Capability may be held for the tick: look a neighbour's up once per tick and reuse it for
every move that tick, as pipes and machines do. Ask again on the next tick, since the block
entity behind it may be gone by then. For a wasm mod each lookup crosses the sandbox (about
2.5 µs, see Building a mod to mod.wasm), so holding it matters more there.
An item's own capability is reached where its stack lives, an ItemPlace: a player's slot, hand or cursor, a
block entity's slot, an item entity, a stack inside another item (ItemPlace::Nested) or a mod's own place
(ItemPlace::Mod, read and written by the PlaceProvider it registers with r.place_provider).
r.item_capability(&ITEM_ITEMS, "mymod:backpack", |host, place| Some(Box::new(Backpack))) answers
ITEM_ITEMS for an item with a ResourceHandler<ItemResource> made for that place, and
r.item_capability_before_game ahead of the game's own over item_container;
place.capability(host, &ITEM_ITEMS) (ItemAccess.getCapability) asks them in the same order. A handler keeps its contents in the
stack, and reads and writes it through the place, as NeoForge 26.3's ItemAccess: place.stack(host),
place.resource(host) and place.amount(host), and place.insert, place.extract and place.exchange(host, tx, &resource, amount), which answer how much moved. Each runs in a transaction of its own, nested in tx, and changes
the place where it is, journaled, so an abort puts it back at every level of an item inside an item. A handler
answering more than it was asked or offered is a mod bug: the move it was part of is undone, and the game warns
once, naming the mod. What doesn't stack with the place, or doesn't fit, goes where vanilla puts an item split off
it, so exchange(host, tx, &filled, 1) on a stack of empty buckets is vanilla's filled bucket: from a player's
place into their inventory, else dropped with no thrower (a creative player's hand takes anything and gives them
one if they have none, as forInfiniteMaterials); from an item entity dropped beside it; from a block entity's,
an entity's or an item's slot into that container's other slots (forHandlerIndex). A handler rewrites its item
with an exchange of one. An exchange inside one of two backpacks is done in one of them: that one is rewritten
holding the change and splits off, and the other stays as it was.
A stack's components change the same way, where it lives (NeoForge's ItemResource.with and without). A component
is named by a ComponentId, checked once as a namespaced id (ComponentId::new("minecraft:damage"), None for
Damage or !minecraft:food). resource.component(&id) reads the stack's own value in its patch as saved;
resource.with_component(host, &id, value) sets it as vanilla's PatchedDataComponentMap.set does, keeping no value
of the stack's own where value is the item's default (host.item_default_component(item, id), compared as the component's codec reads it: a vanilla
component's numbers by exact value, custom_data and the other raw NBT components and a mod's own type for type), and resource.without_component(host, &id) removes the component as remove does: the stack's own value
goes, and where the item has the component by default a removal ("!minecraft:max_damage": {}) is recorded in the
patch.
An exchange of one puts the changed item in place: place.exchange(host, tx, &place.resource(host)?.with_component(host, &damage, Tag::Int(5)), 1) damages one sword of a stack, splitting it off.
An entity's items
entity.capability(host, &ENTITY_ITEMS, ()) (NeoForge 26.3's Capabilities.Item.ENTITY) is an entity's items, None for a
stale handle and an entity outside the level in place. The game answers it for a player with their inventory
(PlayerInventoryWrapper), indices as vanilla's Inventory numbers them: 0..36 the main inventory (0..9 the hotbar),
36..40 the armour from feet to head, 40 the off hand. Each index is the player's ItemPlace::PlayerSlot and takes only
what fits there: an armour slot one of what its equippable names it for (ArmorSlot: its allowed entities hold the
player by id or by tag), and bound armour comes off only in creative. insert_any, without an index, goes where
Inventory.add puts a stack: onto the same item in the selected slot, the off hand and the main inventory in order,
then into its empty slots, never the armour. For a chest or hopper minecart, a chest boat and a chested donkey, mule or
llama the game answers with their container's slots (ItemPlace::EntitySlot). A handler kept past its entity's removal
or its world's close reaches nothing. r.entity_capability(&ENTITY_ITEMS, "mymod:robot", |host, robot, ()| …) answers it for a mod's entity type, and r.entity_capability_before_game ahead of the
game's own, asked as block entities' providers are. Giving, taking and counting a player's items go through it:
#![allow(unused)] fn main() { // A trade: each offer is (index, the exact stack it must still hold). A stale offer or no // room for one aborts the transaction, and every move in it is put back. let (a, b) = (seller.capability(host, &ENTITY_ITEMS, ())?, buyer.capability(host, &ENTITY_ITEMS, ())?); host.transaction(|host, tx| { for (from, to, offer) in [(&a, &b, &seller_offer), (&b, &a, &buyer_offer)] { for (index, stack) in offer { let offered = ItemResource::of(stack); let still = from.resource(host, *index).as_ref() == Some(&offered) && from.amount(host, *index) == stack.count(); if !still || from.extract(host, tx, *index, &offered, stack.count()) != stack.count() || to.insert_any(host, tx, &offered, stack.count()) != stack.count() { return Err(()); } } } Ok(()) }) }
Cauldron interactions outside a transaction keep vanilla's effect order. Inside an open
transaction, their statistic award (including player_stat_award), sound and overflow
item toss/spawn wait until the outermost successful commit; an abort emits none of them.
Entity energy and fluids
entity.capability(host, &ENTITY_ENERGY, side) and entity.capability(host, &ENTITY_FLUIDS, side) reach a mod's
entity energy and fluid handlers (NeoForge 26.3's Capabilities.Energy.ENTITY and Capabilities.Fluid.ENTITY), where
side is the Option<Direction> of the entity the lookup reaches it from, None for no particular side. Vanilla
answers neither; register a provider with r.entity_capability(&ENTITY_ENERGY, "mymod:robot", |host, robot, side| …),
which is given that side and may answer differently for each. An entity capability's context is its key's second type:
() for ENTITY_ITEMS, Option<Direction> for these two and for ENTITY_AUTOMATION. The existing move_energy and move_resources transfer
between these and block or item handlers inside a transaction.
Entity automation
entity.capability(host, &ENTITY_AUTOMATION, side) (NeoForge 26.3's Capabilities.Item.ENTITY_AUTOMATION, "automation-accessible
inventory of an entity (boats, minecarts, etc)") is the items of an entity as a hopper reaches them, with the same
Option<Direction> context as the energy and fluid keys. The game answers it for the container entities
EntitySelector.CONTAINER_ENTITY_SELECTOR picks (chest and hopper minecarts, chest boats) with the slots their
ENTITY_ITEMS handler has; a player and a chested donkey, mule or llama answer none. A hopper
pushing into the entity in front of it asks for the side it faces the entity from (Up for a hopper over it), a hopper
pulling from the entity above it for Down, and a dropper for the side it drops onto. Beside the game's container
entities, those entities at the target whose mod provider answers it join the candidates, and the hopper picks one at
random with the draw getEntityContainer makes; a mod handler takes the container's place and moves one item at a time
in a transaction, as a mod block's ITEMS does. r.entity_capability(&ENTITY_AUTOMATION, "mymod:robot", |host, robot, side| …)
registers one, and r.entity_capability_before_game answers ahead of the game's own for a minecart or a boat.
A menu's slots
Mod-created recipe input sets
Hooks::recipe_property_sets runs once after recipes resolve for each load and again after
/reload. Add RecipePropertyAddition::new("mymod:cooking_pot_input", items) to its
event.additions: a key in the listener's manifest-owned namespaces creates a set. Ownership
uses the package ID plus the manifest's namespaces, so a package named
ported_farmersdelight with namespaces = ["farmersdelight"] can create
farmersdelight:cooking_pot_input. A later listener may extend an existing set but cannot
create a new foreign key. Invalid keys, unknown vanilla keys and air are skipped with a
warning; unregistered items do not participate in acceptance or network sync.
Attach a MenuPart to the registered menu type and filter its input slot with the same set:
struct PotInput;
#[mod_api::horizon_part]
impl mod_api::MenuPart for PotInput {
fn menu_slot_rules(
&self,
context: &dyn mod_api::LoopContext,
event: &mut mod_api::MenuSlotRulesEvent<'_>,
) {
if event.slot == 0 {
event.may_place &= context.mod_recipe_property_contains(
"farmersdelight:cooking_pot_input", event.stack.item(),
);
}
}
}
// In Hooks::register:
// registrar.menu_part("farmersdelight:cooking_pot", PotInput);
The Host exposes the same mod_recipe_property_contains(set, ItemKey) query outside
restricted menu callbacks. This query covers mod-created sets only: unknown sets, unknown
items and vanilla set keys return false. Existing vanilla station acceptance is unchanged.
The server reads its owning load; the client reads local preload sets before its first
UpdateRecipes, then the authoritative server generation. A packet replaces the entire
generation, including an empty one. Closing a screen or changing dimension retains it;
disconnecting discards it. Reads allocate nothing and do not rescan recipes or registries.
There is no second hook and no per-slot load callback.
Client hooks can read the local player's menu with host.local_player()?.container_menu(host),
including the inventory when no container is open. MenuRef::slot_count, slot and carried
read on either side; slot and carried return an outer None when the handle or slot is
unavailable and Some(None) for an empty slot. Each read checks the world epoch and menu generation,
so a handle cannot read a replacement, reopened or respawned menu. These methods do not click or
change slots.
Mutable client hooks can submit a vanilla click with
menu.click(host, &[mod_api::MenuClick::QuickMove { slot }]). The slot is the same menu-order
index used by slot, not a hotbar index or a GUI slot id. For example:
/// Shift-click a slot in the local player's current menu.
pub fn quick_move(host: &mut dyn mod_api::ClientHost, slot: usize) -> bool {
let Some(menu) = host
.local_player()
.and_then(|player| player.container_menu(host))
else {
return false;
};
menu.click(host, &[mod_api::MenuClick::QuickMove { slot }])
}
The typed actions are Pickup (with MouseButton::Left or Right), QuickMove,
Swap (with SwapWith::Hotbar(0..=8) or Offhand), Clone, Throw,
Drag and PickupAll. Pickup accepts None for outside, dropping the carried stack.
A Drag { kind, slots } owns its menu-order slot list and always submits a complete gesture;
DragKind::Split divides evenly, One places one per slot and Clone fills slots in creative mode.
Every action in a batch is validated before any prediction or sending; out-of-range slots/hotbar
indices, creative-only actions in survival and empty batches refuse without effects.
Stale/replaced/respawned menus, spectators, non-client hosts and the creative inventory screen refuse with false.
true means submitted, not server acceptance: prediction uses the normal menu engine and sends
ContainerClick with its state id, changed slots and carried item. Server corrections still win.
Inventory reads and direct server-side item transactions keep their existing contracts; a click does
not expose direct client inventory writes or enforce a mod's locked-slot policy. Call from an explicit
input action rather than submitting every render/tick. Diverges from upstream: new helper.
menu.close(host) closes the menu's screen as Escape does and tells the server (ContainerClose);
it returns false while that screen is not open, so the inventory menu closes only while the inventory
screen shows. menu.click_button(host, button) presses one of the open menu's buttons
(ContainerButtonClick, as a loom's pattern list does), for input the screen's own
buttons don't take; the server's menu decides what it does, and the inventory menu has none.
ItemPlace::MenuSlot { menu, slot } is slot slot of a player's open menu, a MenuRef (player.container_menu(host)). It is
a fork addition: NeoForge 26.3's ItemAccess reaches a player's inventory slot (forPlayerSlot) and cursor
(forPlayerCursor), not a menu's slot. It is the storage the slot shows, named by menu.slot_backing(host, slot)
(MenuSlotBacking), and a write goes to that storage through its own handler, so it is kept once by the transaction
and the storage's rules hold: the player's inventory, armour and off hand by their
ENTITY_ITEMS index, a chest's or a furnace's slot through its ITEMS handler, a chest minecart's
or a chested mount's through its ENTITY_ITEMS, and the player's ender chest, inventory crafting grid and a mod's chest
menu (open_menu), kept by the game. The slot's own rules come first, as a click has them:
menu.slot_holds(host, slot, &stack) is how many of a stack it holds (Slot.mayPlace, then Slot.getMaxStackSize: a
furnace's fuel slot one bucket, an armour slot one piece that fits it, a shulker box's slot no shulker box, a result
slot none), and menu.slot_gives(host, slot) whether it gives up what it holds (Slot.mayPickup: bound armour stays
on outside creative). An insert fills that slot alone, and what doesn't fit stays out. A crafting grid a transaction
changed has its result made again at the outermost commit, once for each player however many writes it took
(InventoryMenu.slotsChanged, with native_prepare_item_craft); the result slot takes nothing and gives nothing,
since taking it is a craft. A menu closed, replaced or reopened since the MenuRef was made, or one whose player left,
reads and writes nothing. menu.cursor() is that menu's cursor (ItemPlace::PlayerCursor { menu },
forPlayerCursor), which reaches nothing once the menu is gone, so a kept cursor never reaches the next menu's. The player's screen follows with the tick's container sync.
#![allow(unused)] fn main() { // Put four of the menu's offered emeralds into slot 0 of the open chest, or nothing. let (slot, cursor) = (ItemPlace::MenuSlot { menu, slot: 0 }, menu.cursor()); host.transaction(|host, tx| { let took = cursor.extract(host, tx, &emerald, 4); if took == 4 && slot.insert(host, tx, &emerald, 4) == 4 { Ok(()) } else { Err(()) } }) }
A block's fluids
Fluids are the capability FLUIDS (NeoForge's Capabilities.Fluid.BLOCK), answered as items are, by a
ResourceHandler<FluidResource> in millibuckets. A FluidResource is a fluid id and its data components, without
an amount, kept as an item stack's are (a potion fluid's contents; water and lava have none), and a tank fills only
from empty or with the same fluid and the same components. A FluidStack is one with an amount, as tanks keep it
(resource.to_stack(amount), FluidResource::of(&stack)). The game's own answer is a mod block's tanks by side
(block_entity_fluids) and a cauldron, one tank of 1000 mB that holds water in thirds and lava only whole; a
cauldron has no block entity and is answered by position, and a powder snow cauldron holds no fluid. A cauldron
takes water and lava whatever their components and discards them, so
what comes out of it is plain: the one place a fluid's components are dropped. A mod block's saved tanks take no
fluid with components. level.capability(host, &FLUIDS, pos, side) finds a handler in a LevelRef's level, as
ITEMS' are found, and move_resources moves fluids as it moves items. A move the target takes only part of is
undone and tried again with that part, so a cauldron, which gives whole thirds and never more than asked, gives
what fits: asked for a bucket with room for 500 mB, it gives one level, 334 mB.
Fluid held by an item is ITEM_FLUIDS (NeoForge's Capabilities.Fluid.ITEM), found where its stack lives, as
ITEM_ITEMS is: place.capability(host, &ITEM_FLUIDS) gives a ResourceHandler<FluidResource> for the item at
that ItemPlace, and a write rewrites the item there by an exchange of one, so from a stack of more than one the
changed item splits off (r.item_capability(&ITEM_FLUIDS, "mymod:canister", …) answers it for a mod's item). The
game answers it for vanilla's buckets: one tank of 1000 mB, filled and
drained whole, an empty bucket taking plain water or lava and becoming its full bucket, a full one becoming an empty
bucket. move_resources moves between an item and a block as between two blocks: a full cauldron fills a bucket in a
player's slot, and two thirds of one fill none.
A block's energy
Energy is the capability ENERGY (NeoForge's Capabilities.Energy.BLOCK), answered by an EnergyHandler: an
amount and a capacity, with no resource, and insert and extract written in a transaction as a
ResourceHandler's are. r.block_entity_capability(&ENERGY, &battery, …) (with a declared or looked-up
type key) registers a provider, asked as ITEMS' are, and level.capability(host, &ENERGY, pos, side) finds one. The game's own answer is a mod block
entity's block_entity_energy buffer, as NeoForge's SimpleEnergyHandler: a fill through the sides it comes
through, up to the buffer's room and its limit per insert, and a drain by the sides it leaves by, up to the limit per
extract; a side that reaches neither finds no handler. move_energy(host, &from, &to, max, tx) moves between two
(EnergyHandlerUtil.move) as one step of move_resources: all or nothing, a move the target takes only part of
tried again with that part.
Energy held by an item is ITEM_ENERGY (NeoForge's Capabilities.Energy.ITEM), found where its stack lives with
place.capability(host, &ITEM_ENERGY), as ITEM_FLUIDS is, and moved by move_energy as a block's is. A mod
answers it for its item with r.item_capability(&ITEM_ENERGY, "mymod:cell", …), keeping the energy in the item's
own stack and rewriting it by an exchange of one, so from a stack of more than one the charged item splits off.
Vanilla has no energy items, so the game answers it for none.
The game never trusts an answer past what it asked or offered. A handler answering more than it was asked has its
move undone, in its transaction, and is warned once naming the mod, and move_resources or move_energy returns
Overanswered naming it. Returned from the closure of host.transaction with ?, it aborts the whole move, so
neither end keeps any of it: a broken handler can't make a move overflow, duplicate or lose items, fluid or energy, or
panic the game.
A LivingRef on the server can apply one effect from an item's loaded consumable with
living.apply_consume_effect(host, &stack, index). The index is zero-based in component
order; this does not eat food, shrink the stack or run consume triggers. Some(true) or
Some(false) is the effect's result; None means the item, index or target is unavailable
or the effect is unsupported. Mob random teleport, armor stands and dragons are not
supported. Player random teleport uses the level random as normal.
Mod-defined kinds dispatch through the ordinary consume_effect hook. For these
kinds, Some(true) means dispatch was entered, not that a listener changed game
state; an unclaimed kind still does nothing. Reapplying the same kind from inside
its own handler on the same call stack returns Some(false) without recursion.
Another kind, including one owned by another mod, dispatches normally, and a later
independent call can apply the original kind again. This deliberately reverses
#550's original blanket exclusion of mod-defined kinds; it is not a second consume
hook or an alternate dispatch path.
On a ServerHost, import HostTransaction to call host.transaction(|host, tx| …) (NeoForge 26.3's Transaction). It runs a closure whose Ok commits and whose
Err aborts, nested in the open transaction if any. tx, a &Transaction (NeoForge's TransactionContext), is
what every handler and place write takes, so none happens outside one. Inside, a capability write applies at once, so later steps see it,
and the game keeps each storage as it was before the transaction first wrote it: a block entity's item slots, saved
compound and attachments, a hopper's cooldown, a cauldron's block state, a player's inventory (slots, armour, off
hand and cursor, whether the player is the host or a guest), an entity's inventory (a chest or hopper minecart,
a chest boat, a chested donkey, mule or llama) and an item entity's stack (one emptied stays, empty, until the
outermost commit removes it). A nested commit hands its changes to
the transaction around it, and only the outermost commit makes them final. Then the world is told of each change
once, as SnapshotJournal.onRootCommit: setChanged and comparators, a jukebox starting or stopping, a cauldron's
block update, and an item dropped at a block, thrown by a player (what a filled bucket's split no longer fits) or
by an entity or split off an item entity. An abort puts every storage back quietly, so nothing outside saw the
change. A panic unwinding out of the closure aborts that transaction as it passes, and the one around it stays open. Open one with host.transaction. The game, not the values it hands out, holds the authority, and it keeps writing apart from ending. The closure, like any handler, is lent the Transaction: it names one open transaction by a token the game drew (unguessable in practice), and a write given one that names no open transaction changes nothing. Ending a transaction takes its TransactionSeal, which only host.transaction's guard (or whoever called the raw transaction_open) holds; the raw transaction_close refuses a seal that names no open transaction. So a handler can neither commit the transaction it was lent, nor end one around it, and a value decoded from bytes or kept past its closure does nothing. A transaction left open inside another is aborted when the one around it closes, so a caught panic or an Err still finds the rollback done. A handler keeps no journal of its own:
its state is the game's, so the game's journal covers it. Direct writes are not covered: an item entity's stack,
LivingRef::set_equipment, and the player and armor-stand slot_set paths take effect and are final at once, even
inside a transaction. Any equip/unequip event and equip sound from set_equipment also happen immediately; an
abort does not undo them. This differs from writes through journaled capability handlers.
ItemPlace::EntitySlot { entity, slot } is slot slot of such an entity's inventory (NeoForge's
Capabilities.Item.ENTITY), read and written like any other place.
Reading or writing a minecart's slots unpacks its loot table with no player, as getChestVehicleItem does.
A mod's own capability
A mod can define a capability of its own, as Mekanism defines chemicals: a resource type and a key, published from its root crate (Dependencies between trait mods) for other mods to use.
#![allow(unused)] fn main() { // The chemistry mod's root crate #[derive(Clone, PartialEq)] pub struct Chemical(pub String); pub const CHEMICALS: CapabilityKey<dyn ResourceHandler<Chemical>> = CapabilityKey::new("chem_api:chemical"); }
A mod that names it under [dependencies] builds against the same Chemical and CHEMICALS, so its providers,
r.block_entity_capability(&chem_api::CHEMICALS, &vat, …) (where vat is a declared type key or
r.block_entity_type_key("chem_pipe:vat")), answer the same capability, and
level.capability(host, &chem_api::CHEMICALS, pos, side) finds either mod's handler. move_resources moves a
chemical as it moves items, in the caller's transaction. The game has no answer of its own for a mod's key, so only
the mods' providers do, and a lookup with none registered costs one table check. A handler that keeps its amount in
its block entity's saved data (BlockEntityRef::merge_data) is covered by the game's journal, so an aborted
transaction puts it back. ItemCapabilityKey::new and EntityCapabilityKey::new define item and entity
capabilities the same way.
A key is known by its name, which must be namespaced (chem_api:chemical), and a name has one handler type. Two
mods that each declare their own Chemical under one name, rather than sharing the defining mod's, would never see
each other's handlers. So the game refuses that at startup, naming both mods and both types, and it refuses a key whose name has no
namespace. Both checks run once, when the mods register.
Entity parts and mob goals
r.entity_part("minecraft:zombie", ZombieGoals) attaches an EntityPart to one entity type, a mod's
own or vanilla's. Its method register_goals runs at every Mob construction of that type —
spawned or loaded, the moment vanilla's registerGoals fan-out ends — and the goals it adds join the
mob's two selectors beside the species' own, with the same priorities, flag locks and interruption:
#![allow(unused)] fn main() { struct ZombieGoals; #[mod_api::horizon_part] impl EntityPart for ZombieGoals { fn register_goals(&self, goals: &mut GoalRegistrar<'_>) { // goals.mob().id() is "minecraft:zombie": branch as per-species code does. goals.add_goal(7, Stare::default()); goals.add_target_goal(3, Remember::default()); } } }
goals.add_goal(priority, goal) and goals.add_target_goal(priority, goal) are GoalSelector.addGoal
on the mob's two selectors; GoalFlags claims MOVE, LOOK, JUMP and TARGET as vanilla's Goal.Flag
does, and a higher priority (a lower number) interrupts a running goal holding the same flag. A goal
object is per mob, built fresh at every construction, and its state is never saved: a goal that needs
memory across loads keeps it in attachments. A mob whose type has no part, or a game with no entity
parts at all, pays nothing — the wrapper and the tick's read view exist only where a mod goal runs.
A Goal reads and writes through its GoalContext: mob() is a fresh handle of the mob it runs on,
host() the tick's read view, random() the mob's own random (mob.getRandom(), so a mod's draws
keep the mob's stream in shape). The read view answers the ticking mob itself directly, the player the
tick sees, and every other entity from the tick's snapshot — a getEntities of the moment the mob
list was taken out — so a read the snapshot does not carry (a mob's gear or effects, its species id)
answers None, and the handle docs mark the reads that carry the snapshot rule. Writes apply to the
mob itself through the engine's own paths: move_to(pos, speed) and stop_navigation()
(PathNavigation), look_at(pos) (LookControl), jump(), and set_target(Option<LivingRef>)
(Mob.setTarget, with the write handle's rules: a fox, an enderman or a zombified piglin stores it
with its setter's side effects, and a brain-backed species refuses, since its target is a brain
memory that MobRef::set_brain_target writes from a server host). Writes to other entities
are not part of a goal; the write handles a ServerHost takes do not exist in a goal tick.
Clearing a fox's target ends defending without changing its trusted entities. An enderman's
target controls its creepy flag and attacking speed modifier; clearing it also clears its stared-at
flag. A zombified piglin schedules its first anger sound and next alert when it acquires a target
from none, not when switching between targets. Targeting a player does not count as player damage.
Brain behaviors
Native mods only: entity parts do not cross to wasm yet.
EntityPart::register_behaviors receives a BehaviorRegistrar whenever a brain-driven species builds or rebuilds its brain. Use add(activity, priority, min_duration, max_duration, requirements, behavior) to join a fresh Behavior to the species' supported activity. Registration returns a BehaviorRefusal for an unsupported activity or invalid duration. Behaviors start and stop in vanilla Brain.tick order: priority, activity bucket, then insertion order, with mod behaviors after vanilla behaviors in the same bucket. Their state is not saved. BehaviorCx provides the mob, host, random source and typed brain memory access; each species refuses memories it does not support.
The part's other method, spawn_cluster_size, changes how large a natural spawn group of the type grows
(Mob.getMaxSpawnClusterSize); controlling spawns
shows it.
Entity parts are a native mod's for now: the goal surface does not cross to wasm (see the
attach_entity_part note), so r.entity_part is absent from a wasm build.
Block-state facts
A BlockState is an API-owned value pairing a BlockKey with a block-local canonical ordinal, not a numeric engine state ID or a live object. Facts that need no world are a small
read-only service on any host, both sides: host.block_states() (import HostBlockStates). Each reads the game's own
tables through checked crossings. Fact queries and matching allocate nothing; definition copies the property data for the caller. Property operations on BlockState borrow explicit immutable block metadata; block() and ordinal() need no host:
| Method | Vanilla |
|---|---|
replaceable(state) -> Option<bool> | BlockState.canBeReplaced() |
destroy_speed(state) -> Option<f32> | BlockBehaviour.Properties.destroyTime (getDestroySpeed): stone 1.5, -1 unbreakable |
sound(state) -> Option<BlockSoundType> | BlockState.getSoundType(): volume, pitch and the break, step, place, hit and fall events |
has_block_entity(state) -> Option<bool> | BlockState.hasBlockEntity() |
map_color(state) -> Option<u8> | BlockState.getMapColor: the MapColor.id a map item colours a column by, 0 for none |
states_of(block) -> Option<BlockStateRange> | StateDefinition.getPossibleStates, as owned values in canonical local-ordinal order |
definition(block) | Property names and legal serialized values |
matches(state, block, constraints) | Partial property match with typed invalid-property and invalid-value errors |
Use StateConstraint for each partial constraint; StateMatchError identifies an invalid property or value by index. StateProperty describes a property's legal values. These types are shared by state-definition services.
None for an unknown state or block, including an invalid local ordinal, and on a host with no game behind it (a mod's test double). The ordinal order is defined by the complete canonical property constraints, not by the engine's numeric enumeration.
Fluid-state facts
Import HostFluidStates to reach host.fluid_states() on either owning side.
definition(key) returns Some(FluidDefinition::World { default, properties }) for a world fluid,
Some(FluidDefinition::TankOnly) for a registered tank-only fluid, and None without a loaded registry.
The properties and typed default come from the loaded preparation, not a second vanilla table.
These metadata reads are available in setup, reload and world callbacks after the fluid keys resolve,
not during registration or configuration.
validate(key, constraints) checks partial StateConstraint values in input order without matching
any particular state. It returns Result<(), FluidStateError>: UnknownProperty(index),
InvalidValue(index) or Duplicate(index) identifies the first offending input.
NoWorldState means the fluid is tank-only or the host has no definition; use definition to distinguish them.
type_properties(key) returns a FluidTypeProperties record, or None without a loaded registry or for
a key it doesn't hold; host.fluid_key(name) is None for an unknown name. The record is the same in every
level: description_id, light_level, fall_distance_modifier, and whether the fluid can push entities, be
swum in, drown, extinguish, hydrate and carry boats. Flowing forms answer as their source. The empty fluid and
tank-only fluids emit no light, do nothing to an entity and have a fall modifier of 1. Anything that depends on
a level, such as lava's current or source conversion, isn't in the record.
The record also carries three Fluid facts: explosion_resistance (100 for water, lava and a mod's
world fluid, 0 for the empty or a tank-only fluid), randomly_ticks (lava only) and drip_particle
(minecraft:dripping_water or minecraft:dripping_lava, none for the rest).
legacy_block(state) is FluidState.createLegacyBlock: the water, lava or mod-fluid block that holds a
fluid state (a source is level 0, a flowing amount is 8 - amount, falling adds 8) and air for the empty
fluid. It is None for a state no fluid can be in: a tank-only fluid, an amount above 8, a zero amount
for anything but the empty fluid, or a source that isn't full.
What depends on a position is on LevelRef, not in this service: fluid_tick_delay(host, fluid, pos)
(water 5, lava 30 and 10 in the Nether, a mod fluid's as its fluid_rules parts change it there),
fluid_height, fluid_height_for_camera and fluid_can_be_replaced_with(host, pos, fluid, direction).
fluid_tick(server, pos) runs a fluid's flow step now; schedule_fluid_tick queues one.
FluidState::matches(constraints) is a pure local check of carried vanilla fields. Unknown properties
or noncanonical serialized values return false. A falling source remains a source; a flowing state
of level 8 remains flowing. Empty constraints match any state. Validate separately for indexed errors.
Neither matching nor validation allocates; explicitly copying a definition may allocate.
Unresolved saved tank identities remain FluidIdentity::Unresolved(handle), not world states or tank-only keys.
Fake players
host.fake_player(level, uuid, name) makes, or finds again, a player with no connection in the level in place, as
NeoForge's FakePlayerFactory does: a machine that acts as a player (a deployer, a block breaker). It is no
online player: the tab list, player counts, sleeping, natural spawning, chunk tickets, statistics and saves leave
it out. player.is_fake(host) tells one apart; player.remove_fake(host) removes it.
player.perform_action(host, action) makes it act as a player does, through the game's own path for the action, as
that player's packet would run it:
PlayerAction::UseOn { hand, pos, hit }is a click on a block (ServerPlayerGameMode.useItemOn): reach, the hit's place on the block, the build height, spawn protection and the border, then the block's use and the item'suseOn, which is how a block item is placed;PlayerAction::UseItem { hand }is a use in the air (ServerPlayerGameMode.useItem), by the item's own ray from the player's eyes and look: a bucket clicked on a block passes, as vanilla's does, and empties when used.PlayerAction::InteractOn { hand, target, location }interacts with that entity (Player.interactOn), after the border and interaction-range checks, with the hit relative to its origin. A passed click does not fall back to using the item in the air. The fake keeps its own supplied look, rather than aiming a new ray;PlayerAction::Attack { target }is a hit with the main hand (handleInteract's ATTACK, thenPlayer.attack): the border and the attack range, the native mods'attack_entity, then the blow on a player, a mob or another entity, or a fireball, wind charge or shulker bullet turned back. The blow is scaled by the attack strength, as a player's is, so a fake that hits every tick does a fraction of a full blow. A mob doesn't take the fake as the player to answer;PlayerAction::BreakBlock { pos }breaks the block at once (ServerPlayerGameMode.destroyBlock): reach, the build height, spawn protection and the border, then the game mode's and the held tool's rules (adventure mode can't, a sword in creative can't, and outside creative a block no digging breaks, destroy time -1 such as bedrock, stays), the native mods'break_block, the tool's wear and the drops. There is no digging time: a machine that digs over time counts its own progress and breaks the block when it is done.
Its items are its own inventory: set the stack in its hand first (through ENTITY_ITEMS or an ItemPlace), and
read what is left after. The answer says what happened:
| Answer | Meaning |
|---|---|
Ok(ActionOutcome::Consumed) | Vanilla's InteractionResult consumed (a success or a consume), the blow landed, or the block broke |
Ok(ActionOutcome::Passed) | Nothing used the click, a mod or the game stopped the blow, or the block stayed |
Ok(ActionOutcome::EffectBlocked(effect)) | The click ran, but what it would start was stopped: a menu or screen, sleeping, riding, or going to another dimension (FakeEffect). The click's own side effects have still happened then: the swing seen by others, the hooks that run before the use, vanilla's Success |
Err(ActionRefused::…) | Nothing ran: NotFake (a real player's handle), Stale, Busy (another player's turn, an open transaction, or a hook inside another perform_action), OutOfReach, Protected, InvalidSetup (non-finite scoped viewpoint or entity hit), or InvalidTarget (an interaction or attack on the fake itself or an entity not in its level) |
For a machine using an isolated stack, player.perform_action_scoped(host, action, setup) composes that same
persistent player's action with temporary state. Build PlayerActionSetup::new(stack, eye, yaw, pitch, secondary_use): the fake acts in survival with a fresh standing body (eye height 1.62), that stack in its main
hand, and no other inventory or equipment. Its supplied rotation is used for item rays. Secondary use holds
semantic sneak without changing that standing eye height. No second player is created.
The ScopedActionResult contains outcome (the same ActionOutcome), held (the nonempty main-hand remainder)
and other (other nonempty inventory stacks, then offhand, carried stack and armor). These stacks are returned
to the caller, not kept by the fake. The fake's persistent body, inventory, game mode, look, secondary-use state
and action timers are restored even on refusal; world changes and the usual interaction/Parts hooks remain.
The ordinary fake safety rules still apply: no screens, sleep, rides, dimension travel, online participation,
statistics, advancement credit or network connection. Neither action method can run inside another player turn
or an open transaction; the action's own ordinary write paths still run transactionally.
On either side, player.entity().is_shift_key_down(host) reads semantic secondary use rather than a physical
key mapping or a crouching pose. On the client obtain the reachable local handle with host.local_player()
(or host.entity(id)); it reads live secondary use even before the next movement tick updates the body's
shift flag. On the server the existing entity flag is read, including a scoped fake's setup. Stale handles and
clients without a joined local player return None.
What a machine does beyond a player's action stays in the mod: its own mining progress and catching world drops. Scoped actions retain player reach, protection and border checks; eye placement is supplied by the mod.
Testing a trait mod
Enable the testing feature on the mod's horizon-mod-api test dependency. Then
testing::with_mods takes an owned Mods list and runs a closure with it installed on the current thread.
It restores the previous list even if the closure panics. The test allocation is retained because dispatch exposes
a static slice. Call dispatch_<hook>(&mut host, args…) inside the closure to build the event as the game does.
testing::FakeHost is a server and client host built from one small fake per service, so a test runs without
the game: registries (item reports, items with slots of their own, the loaded mods), players (who is online,
their inventories by slot, what was given to them and shown on their action bar), level (blocks, block entities
with item slots, fluid tanks and energy storage as the game answers them, and entities), commands and
messages (what ran and what was said), and journal, which keeps the players and the level on each transaction
and puts them back when it aborts. Set the world up through those fields, run the code under test with
&mut host, then read the fakes back:
#![allow(unused)] fn main() { use mod_api::testing::{FakeHost, with_mods}; use mod_api::{self, ClientHost, ClientTickPreEvent, Hooks, ModEntry, dispatch_client_tick_pre}; use std::sync::{Arc, atomic::{AtomicUsize, Ordering}}; #[derive(Default)] struct Counter(Arc<AtomicUsize>); #[mod_api::horizon_mod] impl Hooks for Counter { fn client_tick_pre(&self, _: &mut dyn ClientHost, _: &mut ClientTickPreEvent) { self.0.fetch_add(1, Ordering::Relaxed); } } let count = Arc::new(AtomicUsize::new(0)); let mods = [ModEntry::new("counter", Box::new(Counter(count.clone())))]; with_mods(mods.into(), || dispatch_client_tick_pre(&mut FakeHost::new())); assert_eq!(count.load(Ordering::Relaxed), 1); }
A test of item moves puts two chests in the level and reads back what went where; the transaction's journal is the fake's:
#![allow(unused)] fn main() { use mod_api::testing::FakeHost; use mod_api::{HostTransaction, ITEMS, ItemResource, ItemStack, ServerHost, move_resources}; let mut host = FakeHost::new(); let level = host.level.level(); host.level.block_entity([0, 64, 0]).with_items(1); host.level.block_entity([1, 64, 0]).with_items(1); let stone = ItemStack { item: "minecraft:stone".into(), count: 5, damage: 0, extra: None }; host.level.block_entity([0, 64, 0]).items.as_mut().unwrap().slots[0] = Some(stone); let server: &mut dyn ServerHost = &mut host; let from = level.capability(server, &ITEMS, [0, 64, 0], None).unwrap(); let into = level.capability(server, &ITEMS, [1, 64, 0], None).unwrap(); let moved = server.transaction(|host, tx| { move_resources(host, &from, &into, |_: &ItemResource| true, 3, tx) }); assert_eq!(moved.ok(), Some(3)); let into = host.level.block_entity_at([1, 64, 0]).unwrap().items.as_ref().unwrap(); assert_eq!(into.count("minecraft:stone"), 3); }
A Host method no fake answers panics naming it, as every Host method defaults to, so an unexpected call
fails loudly. A test can still write its own double for what the fakes don't model, implementing only what its
hook calls; testing::FakeJournal gives such a host the same transaction journal over its own state.
If a Host method returns a game value a test can't build, a double can return None instead, as with
Players::acting_player() -> Option<PlayerRef>. If the dispatch function itself takes an unconstructible value,
such as dispatch_perform_command's &ServerPlayer, test the logic beneath the hook instead. The level
hosts are small enough to implement whole. NeoForge's GameTest is the in-game counterpart.
Adding a hook
A new hook needs its declaration in the area file of crates/horizon-mod-vocab/src/hooks/ (/// lines and
#[hook(side = "server", contract = "cancel", out = "cancel")] on fn name();; the argument list is in that folder's mod.rs), its native_* function with the placeholder body
let _ = (every, parameter); (all its parameters, in order), and a run of cargo run -p xtask -- mod-api.
Put that function in crates/horizon-client/src/game/native_hooks/<area>.rs, matching the declaration's
area filename. Alternatively, a named hook may live in any logical module under game/, including
area folders, mod.rs and #[path] modules. Its target must name that module and
Game<'_>::native_<name>. Exactly one matching Game method must exist. Outside native_hooks/,
the method must call dispatch_<name> itself: placeholders are not filled there. Remove macros,
cfg and cfg_attr from that method so injections can patch it. A refusal names the hook and
what to change. These checks run at build time and add no dispatch-time work.
The build discovers native hook modules automatically; do not add a module list entry. Shared
support lives in shared.rs; fire.rs includes generated dispatch entry points, not hook implementations.
That command only replaces the placeholder in its owning area file with the dispatch_<hook> call; commit
that source change and the declaration. Fills are idempotent; rerun the command after a failed write. The horizon-mod-api-gen build dependency generates the hook traits,
Host methods and dispatch code into Cargo's OUT_DIR when the game and renderer build, including release
builds. These generated Rust files are not kept in the source tree or committed, and no manual regeneration
is needed after a merge. Every hook needs a mod_api entry: the build fails naming any hook without one. A hook that stands in for a
method a vanilla block, block entity, item or menu overrides also gets a scope and a key
({"contract": "collect", "out": "output", "scope": "block", "key": "state"}): the key is a
BlockState (routed by its prepared block key for block parts), an &ItemStack, a ModBlockEntity or an id &str, and
the generator also puts the method on that scope's part trait (Parts). A body that is neither the
placeholder nor already calls the dispatch is an error, and the generator never changes it. Hooks that are
free functions outside Game are wired by hand: those crates can't see the mod list, so each calls a callback
the executable installs once, such as NativeLoadHooks in horizon-world and NativeClientHooks in the
client library (menu_slot_rules, living_breathe, ingredient). A build reports a placeholder still to
fill instead of changing hand-written source; run cargo run -p xtask -- mod-api to fill it. Mixin injections
go at the head of the body, before the dispatch, so they still run first.
Server attachments
A mod can declare an AttachmentType<T> once and call its get, set, remove,
has and get_existing methods with the hook's host and a Holder. T is
Clone + Send + Sync + 'static; get returns a clone and inserts the default on
first access. has and get_existing read without inserting (NeoForge's hasData
and getExistingData): has says whether a value is stored, saved or set, even
before it decodes, and get_existing returns None instead of the default. Neither
adds anything to the holder's saved data. set runs the optional codec before
changing the value, returning an AttachmentError if it cannot encode.
Keys are the pair (namespace, name); a key already decoded as another Rust type is
an error. On a server, a missing holder is an error, not a new phantom holder; a client
keeps a copy for any holder it is asked about (see the sync paragraph below).
AttachmentError distinguishes MissingHolder, TypeMismatch, Decode, Encode,
InvalidNamespace (a malformed saved compound), InvalidCopyPolicy (death-copy
without a codec) and InvalidSyncPolicy (sync without a codec).
Every host is an AttachmentStore (and host.attachments() gives it): the typed methods
stand on its few erased ones, so they work the same in a native mod and a wasm mod. A
value keeps its own Rust type, shared: reading one clones it out, and copying a holder's
data copies no value.
AttachmentHolder keeps an existing typed handle when a system needs to store a holder
identity: Entity, Player, BlockEntity, Chunk or Level. It is Copy, does not keep
anything loaded, and follows each handle's ordinary stale-world and unload rules. Unlike
the storage-relative Holder, it carries the handle's world scope.
Import AttachmentQueries to use host.query(&TYPE) on either host side. The
returned batch is a snapshot of membership and stored value bindings in the
host's current dimension. Its iter() borrows &T without cloning values and
lazily decodes saved entries; malformed or incompatible values produce row errors,
not defaults. Later sets, removals and unloads appear only in fresh queries. This
is a residency view: an entity queued for removal but not yet swept can still
appear. Captured handles do not keep holders loaded, and stale writes fail.
Interior mutability inside T is not deep-frozen by a snapshot.
On a server, import AttachmentQueriesMut for host.query_mut(&TYPE) (T: Clone).
Each borrowed row has holder(), value(), and explicit set(host, replacement) /
remove(host) methods. These validate residency against the host's current
dimension and then use the ordinary attachment journal. There is no &mut T,
implicit transaction, or commit on drop: wrap a system in host.transaction for
all-or-nothing writes. Unload or replacement produces AttachmentRowError::StaleHolder.
Rows retain captured values even after successful write-through updates.
Traversal follows dense index order: insertion appends, and removal fills its slot
with the last row. The same ordered mutation history is deterministic, but the
order is neither sorted nor stable across structural changes. Retaining a batch
can make the next live mutation copy row metadata and shared pointers once;
retaining many batches keeps their old bindings alive. Native acquisition clones
only the bucket Arc. Erased values from an incompatible
type domain report TypeMismatch rather than falling back to saved decoding.
#![allow(unused)] fn main() { use mod_api::{AttachmentCodec, AttachmentError, AttachmentType, Compound, Holder, Tag}; static COUNTER: AttachmentType<i32> = AttachmentType { namespace: "example", name: "counter", default: || 0, codec: Some(AttachmentCodec { encode: |n| Ok([("count".into(), Tag::Int(*n))].into()), decode: |data: &Compound| match data.get(&"count".into()) { Some(Tag::Int(n)) => Ok(*n), _ => Err(AttachmentError::Decode), }, }), copy_on_death: false, sync: false, }; // Inside a server hook, or mixin code, with `host: &mut dyn ServerHost`: if !COUNTER.has(host, Holder::Chunk([0, 0]))? { /* chunk carries no counter yet */ } let old = COUNTER.get(host, Holder::Chunk([0, 0]))?; COUNTER.set(host, Holder::Chunk([0, 0]), old + 1)?; Ok::<(), AttachmentError>(()) }
For mixin code, see The typed API from mixin code.
Use Holder::Level, Holder::Chunk([x, z]), Holder::BlockEntity([x, y, z]),
Holder::Entity(id), or Holder::Player(uuid). Entity IDs cover every entity in
the level apart from players: mobs, items, experience orbs, projectiles, tridents,
falling blocks, primed TNT, fireworks, boats, minecarts, armor stands, cushions, item
frames, paintings, End crystals, the ender dragon, ominous item spawners and the
server's OtherEntity records. An entity keeps its UUID through saves, so its saved
attachments load back with it; an orb saved with a count above one loads as several
orbs, and only the first keeps the attachments. Player UUIDs cover online guests and
the acting player. Mutable level
hosts can access their level, loaded chunks and block entities; read-only level hosts
cannot access attachments. Unserialized values disappear on unload or restart. A saved
value is stored under horizon:attachments, then the mod namespace and attachment name,
as {value: <compound>, copy_on_death: 1b, sync: 1b} (a flag is omitted when false).
Unknown mods' saved entries survive load and save without being decoded. Level
attachments use the dimension's data/minecraft/horizon_attachments.dat SavedData
file, not level.dat.
A type with sync: true sends its value to Horizon clients when get creates the
default, on set and remove, and on demand with AttachmentType::sync_data(host, holder); changes made in one tick are sent once, at its end. The recipients are the
players who track the holder: guests who were sent a chunk for a chunk or block entity,
who track an entity for an entity, the player and those tracking them for a player, and
everyone in the level for the level. A client also gets all of a holder's synced values
when it starts seeing it: a chunk sent (with its block entities), a level joined or
re-entered, an entity tracked, and its own player. With no native
mod loaded nothing is sent. The wire form is the codec's compound, so sync requires
a codec.
Only Horizon clients are sent values. A message with a namespace or name over 256 characters, a value that cannot be written or a payload past 1 MiB is not sent.
On a client, Host::attachments returns a copy of what the server synced, which get
decodes; set and remove change only that copy, which is dropped when the chunk,
entity or player goes. A client keeps no copy for the level hosts (LevelRandomHost):
they stay server-only there, though their changes on a server are synced.
copy_on_death requires a codec. Player respawn copies opted-in entries via their
encoded representation, including raw entries of an absent mod; other entries are
removed. Living-entity conversion is not supported. This follows the default supplier,
optional server-side persistence, player-respawn option and sync with a StreamCodec
(with syncData) of NeoForge 26.3's public AttachmentType.Builder contract, and
IAttachmentHolder's hasData/getExistingData, but not its holder-aware supplier,
custom copy handler, conditional serialization, the sendToPlayer predicate,
AttachmentSyncHandler partial updates or a wire codec apart from the disk codec.
The API owns these public names. Their engine-independent storage and query index
live in horizon-mod-runtime, which depends only on the shared vocabulary in
horizon-core; world and game storage use the same implementation.
Host exposes the documented Game helpers declared by area in
crates/horizon-mod-vocab/src/host/<area>.rs, one trait per area; Host, ServerHost and ClientHost are the
unions of those traits, so a mod calls through them and never names an area trait. Each helper keeps its
receiver and documentation; a helper without self (config_files) takes
&self in Host. Level services stay on the level hosts above.
For the per-method comparison with NeoForge's block, item, entity, block-entity and fluid extension interfaces,
including which gaps are tracked, see NeoForge extension coverage.
For item registry facts, host.item_crafting_remainder(&stack) returns a count-one
plain remainder stack, or None when there is none or the item is unknown. Horizon
currently stores only the remainder item id, not a template's count or components.
host.block_item(stone) returns its item registry name; an unavailable block mapping
returns None, while a known block with no item returns Some("minecraft:air").
Both reads are available on either side.
For engine contributors, every area method declares its load access once with #[access(...)].
Metadata admits registry facts during setup and reload; WorldOnly, ClientSetup and
PublishedResources retain their phase-specific contracts. The area's host_area attribute
builds the test-double defaults, and the API generator emits every game forwarder at build time.
Implement only the inherent Game function with the same name, not a hand-written forwarder.
Static implementations use #[static_call]; service accessors use #[service].
A helper's receiver also exposes it on that handle. Receivers come from public
*Ref structs in mod_api.rs: BlockEntityRef gives block_entity, for example.
Adding a handle requires no separate generator registration.
The following documented Game helpers are not in the trait yet:
spawn_mob,mob,mobs,block_in_tag,item_in_tag,server_block_in_tag,server_item_in_tag,server_block,set_display,sidebar,experience_orbs,item_entities,player,panel_recipes,recipes,component,set_component,remove_component,spawn_falling_block,explode,container_slotsuses gameplay-private types.with_mod_block_entityis generic, so its signature cannot be called throughdyn Host.
Experience-orb values use vanilla's signed i32 representation in
ExperienceOrb, ServerHost::set_experience_orb, and the pickup and merge hooks.
Reading or changing a value preserves zero and negative saved values rather than
clamping them through an unsigned representation. A negative value does not grant
positive experience when picked up.
Advancement triggers instead live on PlayerRef::trigger_criterion, with a mod-owned
CriterionTest and the game's scoped CriterionContext resource. The context keeps real
world-aware item predicates; it is not a conditions-only snapshot. See
advancement triggers for the contract and
the wasm runtime's current same-group callback limitation.
A mod that consumes a block use can run vanilla advancement predicates and rewards
through PlayerRef::trigger_block_use(host, pos) or
PlayerRef::trigger_item_used_on_block(host, pos, original, after). These server
operations use the player's own level and never read inventory. Default use fires
default_block_use, then any_block_use, both without a tool: the default helper
is specifically tool-free, not general default block use while holding an item.
Item use fires item_used_on_block against original (copied after a before-block
PASS and before block/item effects), then any_block_use against after (that
original hand stack mutated in place, excluding a replacement swapped into the
hand), in that order. Consuming right-click/before-block cancellation already
fires automatic any_block_use only; do not call either pair helper for it,
which would duplicate any and add an inner trigger. This follows
ServerPlayerGameMode.useItemOn and ServerGamePacketListenerImpl.handleUseItemOn;
the predicate evaluators are DefaultBlockInteractionTrigger,
ItemUsedOnLocationTrigger, and AnyBlockInteractionTrigger.
Ok(()) means the player was reached, even if no criterion matched. Failures are
NotServer for a remote client, PlayerUnavailable for a stale, offline or
displaced handle, UnloadedChunk for a position outside loaded chunks, and
Stack(StackWriteError) when the item boundary rejects either stack before any
trigger fires. Native and wasm use the same generated operations. The
minecraft: guard on trigger_criterion remains: this grants no general access
to fire arbitrary vanilla triggers.
The following catalog hooks are not in the trait:
player_command,item_decorations,client_resources_reloaded,camera_shake,screen_key_pressed_pre,screen_char_typed_pre,screen_mouse_scrolled_pre,use_duration,use_animation,can_eat,living_entity_use_item_start,living_entity_use_item_finish,render_hand,shader_paramsandblock_highlight.biome_colors,camera_distance,compute_fog_color,compute_fov,data_pack_files,mob_submit,mouse_button_pre,mouse_button_post,player_respawnedandtags_updated.
The client render_tooltip_texture hook (NeoForge's RenderTooltipEvent.Texture) picks a tooltip box's look. Setting texture to a tooltip style <namespace>:<name> draws the GUI sprites <namespace>:tooltip/<name>_background and _frame from the resource packs, nine-sliced by their gui.scaling metadata or stretched without it, as vanilla draws an item's tooltip_style; the atlas a resource reload restitches is the one drawn. Left None, the box keeps Horizon's solid fills, whose background and top and bottom border gradient colours it changes as mutable ARGB u32 values; it does not change text colours. It covers solid-fill item tooltips (including creative, recipe book, chat, trading and bundle-selected item names), screen text boxes, and native panel tooltips. The stack is None for text-only boxes, including recipe-book item lines where the stack is discarded before drawing. Sprite-based screen tooltips (including options and title screens) use a separate renderer and are not covered.
Tooltip component geometry
render_tooltip_gather_components preserves the mixed text/image list, including
an image inserted immediately after the name. Wrapping splits text in place.
The text pass visits the entire list before the image pass starts; both passes
advance through the same component heights, with 2 extra pixels after the first
component of an item tooltip. Each image callback receives the box's left edge
and its own running top, but the whole content box size, not TooltipImage.size.
That field is the component measurement used to reserve space. The box width is
the widest component; its height is the sum of heights, minus 2 normally, with no
subtraction for item tooltips containing multiple components. Text components
measure 10 pixels high. Factory registration and selectable fonts are separate
capabilities, not provided by this geometry contract.
Camera and collision reads
ClientHost::is_paused() reads the applied client pause state, not whether a pause
screen is open. A multiplayer game continues running while its pause screen is open.
The pause-change pre hook sees the old state; the post hook sees the new state.
ClientHost::playing() is true while the player is in the world with nothing over it: no screen, title, sign
editor or book, so keys and zoom apply. ClientHost::mapping(name) reads a vanilla key mapping (key.forward,
key.left, key.back, key.right, key.jump, key.sneak, key.sprint, key.attack, key.use) as a
KeyMapping: whether it is held (KeyMapping.isDown) and the label of the key it is bound to, as Controls
shows it. It is None for any other name. Both read without &mut, so the HUD and screen hooks can ask them.
ClientHost::clipboard_text() reads the system clipboard (KeyboardHandler.getClipboard, empty text when it
holds none) and ClientHost::set_clipboard_text(text) writes it (setClipboard; empty text copies nothing). Both
work only while the game handles a key, mouse or typed-text event, from the window or from the control socket
(horizon-ctl key, mouse, type and its clicks): a key, mouse or screen hook that fires inside one, such as
Ctrl+V into a mod's own field. At any other moment (a tick, a frame, a packet) the read is None and the write
is false with nothing copied, so no mod reaches the clipboard without the player doing something. Both are
None and false on a server, and take &self, so a screen hook can call them. Vanilla's KeyboardHandler
has no such rule: Horizon adds it.
The camera() helper returns the most recently extracted world frame's camera, or none before one exists. Its position, interpolated eye height, partial tick, angles, final FOV, near/far planes and dimension accompany column-major view and projection matrices. The view is camera-relative and includes bob and spin; projection uses reversed Z. Earlier callbacks in a frame can see the previous frame. block_collision(pos) returns block-local [min_x, min_y, min_z, max_x, max_y, max_z] boxes with an empty collision context; unloaded blocks and collision errors give no boxes.
ClientHost::set_camera_type(type) is a queued client-only effect and takes &self, so
shared callbacks such as camera_pose can request a perspective. camera_type() reports
the latest request immediately; several requests coalesce to the last one. Requests before
or during camera_pose apply before that frame's final camera, FOV and spectator post effects.
The pose hook runs only once: its explicit position or angle overrides survive a refreshed
vanilla camera. Requests from later render callbacks apply at the next frame's extraction.
Setting a type never emits camera_type_cycle; F5 alone cycles, and its final event choice
wins over requests made inside the cycle callback.
ClientHost::crosshair_target() returns the ordinary interaction-range target as
CrosshairTarget::Block { pos, state, hit } or
CrosshairTarget::Entity { entity, point }. The block hit contains its world-space
location, face and inside flag; the entity point is also in world coordinates.
It uses the game's current targeting ray and block/entity reach, respects blocks
occluding entities, and returns None on a miss or without a client world.
It can be read with a screen open. entity is an EntityRef carrying the current
client level's epoch and network entity ID, including dragon parts. It becomes stale
when the client level is replaced or the client disconnects; a same-level respawn
keeps its epoch. This supplies identity, not client entity reads: the server-only
entity data methods still reject client handles. Unlike view_hit and raycast, this does not find blocks
in distant terrain or return a block behind a targeted entity.
Combat targeting
ClientHost::targeting() and set_targeting(ClientTargeting) control liquid picking,
entity occlusion and an optional crosshair entity override. Defaults preserve ordinary
vanilla targeting. Set pick_liquids to include source and flowing fluid shapes, or
hit_through_walls to ignore block occlusion for entity picks and attacks. An entity
override must name a live entity in this client level; stale or self handles make the
setter return false without changing the controls. The setter requires a logged-in
client world. An override is rechecked for reach, held-item range and occlusion when
used and disappears from reads after its level is replaced.
Reach remains attribute-backed: use the server player's living handle and
set_attribute_base(host, "minecraft:block_interaction_range", value) or
"minecraft:entity_interaction_range". The client's cached reach follows the server's
attribute updates. Client targeting controls do not grant extra server reach.
client_attack_target receives a mutable targets list before an ordinary melee click
(spear jabs use their existing path). It starts with the crosshair entity. Replace it
for redirection, append for multiple hits, or clear it to suppress an entity attack.
Flow::Cancel cancels the batch. There is no batch cap; duplicates are sent once
in first-occurrence order. Each target is checked again against live handles,
attackability, reach, held-item range and the occlusion control. Clearing an entity hit
does not mine the block behind it; an empty list on a block or miss keeps the ordinary
path. Each accepted hit uses normal attack packets and prediction. Server validation
and cooldowns still apply. Native and Wasm mods use the same typed API.
Conditional, numeric and categorical item model properties and item tint sources
receive an API-owned ItemStack snapshot. The engine's resolver keeps its own
StackView; that engine type is not part of mod_api. This applies to nested
bundle contents as well as the outer item being drawn.
| Hook | Snapshot | Runtime selection | Answer |
|---|---|---|---|
item_model_property | event.stack: &ItemStack | event.bundle_selected: i32 | event.value: bool |
item_model_range_property | event.stack: &ItemStack | event.bundle_selected: i32 | event.value: f32 |
item_model_select_property | event.stack: &ItemStack | event.bundle_selected: i32 | event.value: &mut Option<String> |
item_tint_source | event.stack: &ItemStack | event.bundle_selected: i32 | event.color: u32 |
bundle_selected is the runtime bundle selection index: -1 means none. It is
not a saved component and is deliberately separate from the stack's component
patch. The patch contains explicit overrides and removals, not merged defaults.
The host is &dyn ClientHost for each callback. Only answer property names or
sources the mod owns; unanswered callbacks retain their existing defaults.
ItemTintSourceEvent.config is a borrowed Tag, converted once from the tint's JSON definition at load. Read fields with Tag::get and match tag variants. Invalid payloads refuse model loading; resolving a tint does not convert or clone its payload.
| Former resolver-view access | Owned API access |
|---|---|
event.stack.item | event.stack.item() returns ItemKey; retain keys for comparisons |
event.stack.count | event.stack.count() |
event.stack.patch | event.stack.components() returns an optional owned NBT patch; use event.stack.get(&descriptor) for a typed component |
event.stack.bundle_selected | event.bundle_selected |
A native or wasm listener sees the same owned values. Snapshot construction occurs only after the subscription check, so a model resolution with no listeners does not allocate or encode a snapshot. An empty or unresolved resolver view does not dispatch an invalid stack; its property or tint retains the unanswered default.
ItemStack is the mod API's own nonempty value, not an engine inventory slot or a borrowed
rendering view. It contains an ItemKey, a nonzero count, an optional numeric damage override
and a lazily allocated component patch. Plain stacks allocate no component storage. An empty
slot or a consumed stack is None; it is never a zero-count ItemStack.
Every loaded item, vanilla or modded, has an ItemKey. Resolve identifiers through
Host::item_key once when loading data and retain the key. Forward references requested during
registration keep their slots when the load completes; unresolved references refuse the load.
Keys are process-local identities, not persistent names. Tags likewise have retained TagKey
identities, while their item and fluid membership is read through Host::tags and changes on
reload. Unknown tags are empty; unavailable authoritative mappings return None.
Stored values and effective components
Count and component edits do not consult the game. new refuses zero; set_count and grow
refuse invalid or overflowing counts without changing the original value. Consuming split
and shrink return optional portions so consuming the last item needs no empty sentinel.
The damage representation has three states:
| Stored state | Effective meaning |
|---|---|
| No numeric override and no removal | Inherit the item's prototype, including its default damage of zero when present. A new plain sword needs no Components allocation. |
Some(0) | An explicitly present zero-damage component, even on an item without a damage default. set_damage(0) creates this state without allocating a patch. |
No numeric override and !minecraft:damage in the patch | The component is explicitly removed, including an inherited default. clear_damage creates this state. |
damage() returns the stored number or zero. damage_component() distinguishes an explicit
zero from no numeric override. set_damage(u32) is infallible; the game validates its effective
maximum when accepting a write. Adding only a maximum-damage override to an item does not
invent a damage component: explicitly adding Some(0) is what makes that component present.
All numeric damage lives in the optional field. Raw damage entries normalize out of the
component patch; malformed numbers, conflicting numeric values, or a simultaneous value and
removal refuse the entire replacement. Generic component setters and raw set_components
therefore return Result. A refusal leaves the stack unchanged. Typed damage setters do not
need an error path.
ItemResource::with_component is also fallible for raw component values. Its default-value
elision and without_component preserve the normal prototype-plus-patch semantics. The latter
records real NBT removal entries for default components; it is not an unknown-value fallback.
Game boundaries
A consume or death-protection override whose opaque native effect payload cannot be decoded
refuses the whole stack write with StackWriteError::InvalidEffectPayload, before any
resident state changes. The engine's infallible preserved-component setter remains infallible:
invalid replacements retain its prior raw component and decoded cache rather than erasing them.
The items host area owns metadata reads such as max_stack_size(ItemKey) and
max_damage(ItemKey). A stack does not take a host just to read a maximum, and an unavailable
maximum never turns into a fabricated limit of 64.
The game validates effective count and durability limits, including component overrides, when
accepting a resident write. An effective max_stack_size must be in 1..=99, and the
presence of max_damage forbids a stack limit above one, even when the current count is one.
Removing max_stack_size uses the absent limit of one. Invalid values produce typed errors,
not clamped values. A transfer request is different: ItemResource plus a separate amount can request or
return more than one resident stack's capacity. The resident writes it produces still have to
pass validation.
Game inventory, menus, trades and models keep their engine-owned representations. Native
hooks and host methods exchange owned API snapshots. The game converts only after a listener
guard, and stages every mutable result before committing any captured output. Invalid hook
edits retain those captured engine values. Consuming mutable events such as bonemeal and
item_after_vertical_collision lend Option<ItemStack> so a listener can consume the value by
setting None.
The item_stacked_on_other bridge commits its clicked slot, cursor and cancellation flags as
one unit, including the menu's slot-operation queues and map-id reservations. The block-entity
tick, menu-button and menu-quick-move bridges validate their captured snapshot outputs together
before applying them. These are output guarantees, not whole-world transactions: independent
ServerHost storage writes, commands, messages and other effects are not rolled back on a
refused output. Carrier-form callbacks such as bonemeal and item_after_vertical_collision
also retain their captured stack outputs on refusal but do not journal independent host writes.
The storage-journal follow-up covers that gap.
Restricted in-write LevelHost callbacks keep their existing write and random-draw order.
Item-model callbacks receive the owned stack and a separate bundle_selected number (-1
means none). Runtime selection is not a persistent component. Drawing receives the API-owned
ItemDisplayContext, not the renderer's enum.
The NBT escape
ItemStack::from_nbt(host, compound) and to_nbt(host) are the NBT escape. They live on the
plain API value, not as a second engine implementation in the items host area. They use only
typed host key lookup and canonical id metadata, so native and WebAssembly mods execute the
same parsing and serialization code. Persistent NBT contains identifiers, never process-local
key slots. Unknown ids, malformed values and zero counts fail decoding; unavailable id
metadata fails encoding.
The escape preserves explicit zero damage, default-component removals and opaque component NBT. It does not apply resident maxima or materialize prototype defaults. Those remain the responsibility of the metadata service and game write boundary respectively.
A server mod asks for a call back after some server ticks with schedule_task, the stand-in for
vanilla's MinecraftServer.tell(new TickTask(getTickCount() + delay, runnable)). A closure cannot
cross into a wasm mod or outlive the host a hook borrows, so the callback is a hook: the mod passes
a u64 token and Hooks::scheduled_task hands it back.
const REMINDER: u64 = 1;
impl Hooks for MyMod {
fn player_logged_in(&self, host: &mut dyn ServerHost, player: u128, name: &str) {
// 20 server ticks from now: one second at the normal rate.
if let Ok(id) = host.schedule_task(20, REMINDER) {
// Keep `id` if the reminder may need to be withdrawn: host.cancel_task(id).
}
}
fn scheduled_task(&self, host: &mut dyn ServerHost, id: TaskId, owner: &str, token: u64) {
if owner != "mymod" || token != REMINDER {
return; // every mod is asked about every task
}
// The task has fired and its id is spent.
}
}
| Call | Meaning |
|---|---|
host.schedule_task(delay_ticks, token) -> Result<TaskId, ScheduleError> | A task due delay_ticks server ticks from now. 0 and 1 both mean the next tick. |
host.cancel_task(id) -> bool | Withdraws a task. True only if it is yours and still waiting. |
Hooks::scheduled_task(host, id, owner, token) | The callback, on the server thread. |
Timing. Delays count ticks the server runs: a paused integrated server does not advance them. A
task fires at the start of its due tick, right after server_tick's start phase, in the order the
tasks were scheduled. A task never fires in the tick that scheduled it, whichever hook scheduled it,
the tick's start hook and scheduled_task itself included: it fires on a later tick.
Ownership. The task belongs to the mod whose hook called schedule_task; the game takes that from
the running hook, never from an argument. Outside a mod's hook the call returns
ScheduleError::NotInMod. cancel_task answers false for another mod's id, an unknown one, and one
that has fired or been cancelled. Every mod hears scheduled_task for every due task, so a mod
compares owner with its own id and ignores the rest. Ids are never reused in a world.
Limits. A mod may have MAX_PENDING_TASKS (4096) tasks waiting; the next schedule_task returns
ScheduleError::TooMany until one fires or is cancelled. A task that panics does not drop the others
due on the same tick: they still run, and the panic goes on afterwards as any hook's does.
Lifetime. The queue belongs to the running world. It is dropped when the world opens or closes (including a failed open or close), and is never saved. A mod that must outlive a restart writes its deadline to its own storage and schedules again when the world opens. A mod's tasks are not cancelled when the mod unloads; the world's close is the end of them.
EntityRef::extinguish_fire(host) exposes vanilla Entity.extinguishFire on
players, guests, mobs, armor stands and dropped items. If the entity is burning,
it first emits minecraft:entity.generic.extinguish_fire at its feet with volume
0.7 and vanilla's pitch from two ordered random float draws. It then clears
positive remaining fire ticks, preserving a negative fire-immunity countdown.
Fire-immune mob kinds and dropped stacks resistant to in-fire damage do not
count as burning, even when their timer is positive.
The operation reuses the engine's mob sound outbox and entity random, and the
player/armor-stand cosmetic random and captured sound route. Dropped items use
their own random. Sounds use the entity's sound source, including Players for
a guest rather than a generic sound category. It does not draw pitch randomness
for a non-burning entity. It returns false for stale or unsupported handles.
EntityRef::clear_fire(host) is the distinct vanilla Entity.clearFire operation:
it is silent, draws no random, and preserves the same negative countdown. Choose
it only when the original behavior explicitly calls clearFire, rather than
using it as a substitute for extinguishing.
Trait mods (draft)
cargo horizon-mod new <path> --template native pins an exact Horizon commit (the newest prebuilt game's,
or --horizon <commit>'s; cargo horizon-mod update repins) and writes a flat project: horizon-mod.toml with [native], Cargo.toml, src/lib.rs, and empty assets/
and data/ directories. The template greets each player who joins. Build it with
horizon-launcher native-build <path> --trust-native-code; cargo horizon-mod build packages the same
project into a .hmod without compiling it (the manifest).
The root Rust package depends on horizon-mod-api (lib mod_api), never on the game. Its root src/lib.rs marks one
impl Hooks with the attribute:
use mod_api::{self, Hooks};
#[derive(Default)]
struct Mod;
#[mod_api::horizon_mod]
impl Hooks for Mod {
// Implement the hooks this mod needs.
}
The attribute generates the hooks() factory and derives optional hook subscriptions from this impl.
The mod type implements Default; do not write a second factory. Keep the marked impl at the crate root.
Rustc's "cannot find function hooks" at the launcher's generated call means the marked implementation
is not at the crate root. The launcher links this package into a generated game executable and calls
the generated factory in dependency order (a mod after the mods it depends on, else in bundle order).
A mod with no raw injections skips horizon-mixins; typed hooks need no injection declarations.
Dependencies follow the pinned Horizon Cargo.lock rules in The mod's crate.
Dependencies between trait mods
A mod can publish functions and statics from its root crate for another mod to call.
Declare dependencies in horizon-mod.toml, not Cargo.toml:
[dependencies]
base_mod = "^1"
extra_mod = { version = "^2", optional = true }
Requirements use Cargo semver syntax and are checked against each installed mod's horizon-mod.toml
version, not its Cargo package version. Content-only mods use these declarations to validate their
requirements and load their layers after dependencies. Code dependencies additionally require API packages
for the dependent and every installed dependency.
Missing required dependencies, incompatible versions and cycles stop the build with the mods named.
An absent optional dependency is permitted; an installed optional dependency must match its requirement.
The launcher adds a path dependency using the mod ID as the Cargo alias and the target's actual Cargo
package name. Optional dependencies also get a Cargo feature named exactly as their mod ID, enabled
only when that mod is installed. Guard calls with if_installed!, which builds the block only with
that feature and runs it only when the mod is installed, so the same source works when built to
mod.wasm, where the library is linked at load and an absent one's functions trap:
mod_api::if_installed!(host, extra_mod, {
extra_mod::register_extra(host);
});
Do not define these dependencies or features yourself in Cargo.toml. Public statics must use appropriate synchronization for shared and reentrant hooks; private state in another mod's hook object is not exposed.
Trait dispatch follows dependency order, with ties kept in bundle order. Mixin injection order remains horizon-mixins' priority, mod ID, then injection ID for source splices. Function-slot listeners instead use descending priority, dependency/registration order, then injection ID within a mod; call-site listeners retain mod-ID tie ordering.
Load phases and access
Native and wasm hooks share the installed dependency-ordered mod slice, including registration,
config loading and reload-listener registration. The shared resolver keeps bundle-order ties,
checks present optional dependencies too, and refuses cycles for both build targets. Content-only
packages stay after code in the loaded-mod inventory; listener-specific edges still use ReloadOrder.
Each owning thread progresses through Packages → Registration → Setup → Config → World.
| Phase | Available access |
|---|---|
| Packages | Package discovery; no game Host operations. |
| Registration | Declarations through RegistrationContext, with its mod-list, registry and settings reads; no Host. |
| Setup | Client setup reads, published asset/data queries and registry/settings/mod-list reads; no world handles or transactions. |
| Config | Settings reads and the existing mod-scoped ConfigFiles service; no world handles, transactions or other game operations. |
| Reloading | Published asset/data queries, registry facts, settings and mod-list reads, listener registration, and existing DataLoadContext callbacks; no world handles or transactions. |
| World | The owning game's side-typed ServerHost or ClientHost. |
RegistrationContext::instance_dir() reads the instance directory captured before
registration begins, as an InstanceDirectory: Unix(Vec<u8>) preserves raw Unix
path bytes, and Windows(Vec<u16>) preserves Windows UTF-16 code units, including
unpaired surrogates. Never convert Unix bytes with a lossy UTF-8 conversion when
constructing a native filesystem path. Wasm receives the identical host-encoded
value through Wire as data only; this does not grant filesystem access. A context
with no engine snapshot returns RegistrationReadError::Unavailable.
Registration runs once: register, component_types, blocks, items and standalone_models
receive only RegistrationContext. Its mods(), registry(name) and setting(mod_id, key) read
one view captured before the first of them runs, so every registration step sees the same values
whatever the dispatch order: the loaded packages, the built-in registries as registries.json
lists them (not what mods declare during registration) and the settings on disk at that moment. A
mod validates the keys it declares against them from Hooks::register; a name that is not a
namespaced identifier, an unknown registry, an empty or path-like setting key, a package that is
not loaded, or a context the engine gave no data returns a RegistrationReadError. Client setup
runs once after the initial asset apply. The window's Client/Common configs load once immediately after client setup and before title,
connection, input, render or ordinary tick callbacks. This includes pack-error recovery: the failed
selection is cleared and saved during initialization, but its options screen opens only after Config
completes. A title-screen callback is already in World: world-handle constructors can return None,
rather than being forbidden in Setup. A dedicated
server loads Common once before its first world; Server configs load and unload per world.
A second world does not repeat registration or client setup. Config loading can read settings and
use the existing scoped ConfigFiles service to read or save its own config files. World handles,
transactions and other game operations panic with Config in the diagnostic.
Config file polling is not a phase transition, and Server config unloading leaves the World phase.
Every data or asset reload enters Reloading, including listener registration, prepare, apply and
the reload completion notice. Initial loads can nest in Setup; runtime reloads nest in World.
Returning, including after a failed prepare, restores the entering phase. Published asset/data
queries are classified in the catalog (resource_read), not granted by helper-name exceptions.
They read the currently published generation, not pending prepare output: completion callbacks can
refresh sprite and recipe caches after apply, including on first world open, without gaining world
handles or transactions. Their rustdoc names their load-phase availability. Server listener
registration retains &dyn Host, restricted to these published facts, registry facts, settings and
mod-list queries; callbacks already taking DataLoadContext keep that context. Neither permits
opening a world handle or transaction. The first client registration and prepare run on the loader
thread; later registration runs on the render thread, prepare on its worker, and apply on the owning
game thread. Startup's empty client Host is not an extra permission.
DataLoadOrigin and LoopSide are API-owned enums, not engine exports. Load and loop
callbacks read frozen registry and settings metadata through DataLoadContext and LoopContext.
The engine supplies borrowed LoadReader and LoopReader implementations; the API's owning
views contain no engine types and do not copy or rescan the resource snapshot. Mod listeners
keep using the context traits, with the same origin, side and registry ordering on both targets.
The reader adapters themselves are native host implementation details, not wasm resources.
The game alone advances phases. An illegal or wrong-thread advance is refused before dispatch;
a forbidden mod call panics naming the phase, rather than returning a Result a listener could ignore.
There is no separate common-setup, load-complete or intermod event: registration and the first server
reload cover common setup, client setup is client_setup, dedicated setup is server_about_to_start,
and dependencies express intermod ordering. Load complete is the edge after initial Config and before
server_about_to_start, not a hook.
Settings snapshots accept only regular UTF-8 files, without following file or package-directory symlinks (including internal links). Invalid UTF-8, special files and refused entries are absent, not errors. Each file is limited to 64 KiB before trimming, each mod to 1,024 captured keys, and the whole snapshot to 16 MiB before trimming. Over-limit keys are omitted in sorted package-id and key-name order. Enumeration is limited to 4,096 names per package; packages exceeding that budget are omitted entirely rather than retaining an unstable enumeration prefix. One warning per refusal class is emitted per snapshot. These generous limits cover the small scalar setting values used by the settings API; they do not apply to mod data files.
Native entry point and diagnostics
The generated executable uses the installed game's crates/horizon-client/src/native_main.rs.template.
The launcher supplies only ordered literal mod IDs and hook factory paths. The template owns entry
construction, the allocator and the canonical game identity. Source without this template is refused
before staging trait mods: update the game first.
A mod whose bootstrap cannot complete should call cx.refuse("reason") inside
Hooks::register, then return. This refuses only the calling mod's registration:
there is no mod ID argument. Its declarations from that registration (before or
after the call) are discarded. Later declarations do not add secondary validation
errors, and another mod's declarations are unchanged. Native and wasm report the
same diagnostic through the normal registration error path. This does not panic
or exit the process. The first reason wins, including an empty reason. Reasons
are preserved verbatim up to 4096 UTF-8 bytes; longer strings are truncated at the
last character boundary within that limit.
A panic in a trait mod's hook adds in native mod <id>, hook <hook> to the panic's message, in the crash report
and in the server thread's log line, with the horizon-mod.toml id. Each entry passed to
entry::main_with must bind its id and hooks together: ModEntry::new("example", example::hooks()).
The ordered list is Mods = Arc<[ModEntry]>; there is no separate naming step or unnamed entry.
Before installation, mod_api::mods() still returns an empty list. Parts retain their registering mod's
id for startup errors and hook panics. The game stops. A point listener's panic names its mod and
method; render hooks are not named yet.
A native runtime that hosts converted packages (Portizon's wasm packages, a Java interpreter's jars)
declares the packages it loaded with mod_api::content::crash::ConvertedPackages::new(runtime_id, ids) and
marks a guest call with mod_api::content::crash::ConvertedPackage::enter(export, site). The scope nests,
restores the previous one on return and while a panic unwinds, and adds in converted package <id>, export <export>, call site <site> below the runtime's in native mod line in the report. A package
id the runtime did not declare is refused, as is a scope entered while another mod's call is the
current one, so guest code cannot impersonate a mod the runtime did not load.
The guard is thread-affine (not Send or Sync), and attribution ends when the native frame that entered it exits, even if the converted guard is retained. A nested native frame hides the outer frame's package until it returns. Worker threads have no scope unless the runtime enters a native call and package scope there. Registry ids are stored, not interned: entering a package clones an owned id on every call (borrowed static ids do not allocate).
A code mod may listen at the game's injection points as a rare escape hatch, through a crate-root function
named by [[inject]].with, taking a mod_points record; the form
and its limits are in Injections.
Hook dispatch and level hosts
A linked trait mod implements mod_api::Hooks. A hook function is not an injection point, so
[[inject]] never runs inside a hook's dispatch; a head injection that cancels the game function
calling a hook keeps the hook from firing at all. A claimed cancellation reaches only trait listeners that receive cancelled events.
Otherwise, trait mods run by priority, then in dependency order. Once a mod cancels, later mods are skipped unless
they receive cancelled events. For replacement hooks, the first Some wins. Collection hooks call every mod.
Receiverless hooks have no Host.
To save from inside a hook, use level.request_save(host) so the save runs at the next settled
start-of-tick boundary. A synchronous save-all while an entity pass or held-out row is borrowed
fails with vanilla's commands.save.failed feedback and a failed command result, without saving.
To emit a vibration-producing game event, use level.game_event(host, event, at, context).
GameEventContext carries an optional source entity and affected block state; see
game events with context for listener semantics and refusals.
#[listener] on a hook method sets both, as NeoForge's EventPriority and receiveCanceled do. Two mods, each
its own crate:
#![allow(unused)] fn main() { #[mod_api::horizon_mod] impl Hooks for Protection { // Runs before mods at the default priority, 0; higher runs first. #[listener(priority = 100)] fn break_block(&self, host: &mut dyn ServerHost, event: &mut BreakBlockEvent<'_>) -> Flow { if self.protected(host, event.pos) { Flow::Cancel } else { Flow::Continue } } } #[mod_api::horizon_mod] impl Hooks for Override { // Still told after a cancel, and may undo it. #[listener(priority = -100, receive_cancelled)] fn break_block(&self, host: &mut dyn ServerHost, event: &mut BreakBlockEvent<'_>) -> Flow { if event.cancelled && self.allowed(host, event.pos) { event.cancelled = false; } Flow::Continue } } }
priority is an i32, 0 when omitted; mods of equal priority keep dependency order, as [[inject]]
priorities do (Injections). Each hook's order is fixed once, when the mods are
installed. Every cancellable hook's event has a cancelled field: a mod without receive_cancelled always sees
false, since it is not called once the event is cancelled. A mod that receives cancelled events and sets it back to
false lets the game's behaviour run, and the mods after it are told again. An event the game hands over already
cancelled reaches only mods that receive cancelled events. receive_cancelled on a hook without a cancel does not
compile, and #[listener] is for a trait mod's hooks only: a part is called for its own thing, in no order.
Native and wasm trait mods use the same listener ordering and cancellation rules. Wasm subscription records carry the hook name, signed priority, and receive-cancelled flag through the shared-memory Wire boundary at load time. The event's cancelled field crosses with its other writable fields. No component-model adapter is involved.
Each hook method takes its host, where it has one, and one event:
#![allow(unused)] fn main() { fn living_incoming_damage( &self, _level: &mut dyn LevelRandomHost, event: &mut LivingIncomingDamageEvent<'_>, ) -> Flow { if event.kind == "minecraft:pig" { let halved = event.container.new_damage() * 0.5; event.container.set_new_damage(halved); } Flow::Continue } }
The event is a generated struct named after the catalog hook, in CamelCase plus Event, with
the hook point's arguments as public fields: values as values and borrowed ones as references (event.kind). A
number or bool a mod may change is a plain field, written back once every mod has run, even after a Cancel;
anything else a mod may change, a game struct or a list, is a &mut field. A cancellation or replacement is still
the return value; a collection hook's list is a field (event.lines). One event goes through every mod, so each
sees the changes before it. Because no mod can build or match the whole
struct, a later version can add a field without breaking any mod, as NeoForge's event objects do; the cost is that
a mod's own tests drive a hook through its dispatch function (Testing a trait mod).
Hook methods take &self and require Send + Sync. The process-wide mod list is installed once, like mixin module statics. One mod instance serves both
the window's game and the server-thread game. A hook can also fire again inside
the same mod: for example, LevelRef::set_block fires block_changed. Mods keep mutable
state with interior mutability so these calls can share the instance safely. Data-load
hooks can run on any loading thread; item default changes must be identical on every side.
The trait has every hook declared under crates/horizon-mod-vocab/src/hooks/ (Adding a hook).
Level hooks use access-specific hosts rather than Host. LevelHost provides
block_state through LevelAccess::block_state, client-side detection and
state_with_property. state_string(state) serializes the full name and properties, or returns None
for an unknown state; min_y() and height() read the level's build range. It also reads light and loaded state, through the game's own queries:
brightness(LightLayer::Block | Sky, pos) (getBrightness, the stored 0 to 15),
raw_brightness(pos, sky_darken) (getRawBrightness, which CropBlock.randomTick asks with 0)
and is_loaded(pos) (Level.isLoaded: inside the build height and in a loaded chunk),
is_raining_at(pos) (Level.isRainingAt, false where pos is not loaded) and enabled_features()
(LevelReader.enabledFeatures, a borrowed FeatureFlags<'_>). Neither allocates.
None of them loads a chunk. Light above the build height reads as full sky and below it as
none, and a chunk that isn't loaded reads as the game's query does (full sky, no block light),
so check is_loaded first. It also answers tag and fluid questions without a Host: block_in_tag(block_key, tag),
fluid_state(pos) (getFluidState, None where the block read is),
state_fluid_state(state) and fluid_in_tag(fluid_name, tag). Fluid reads return FluidState with a
FluidKey, amount, falling flag and engine-derived source flag; is_empty() and is_source() report
those states without a registry lookup. block_state_id(name) parses a block and properties, and block_state_default(block) gives a block's default state, as on Host; both return None for invalid input or unavailable context. Tag reads are None when authoritative tags are unavailable, including on a client before its server's tags are applied. For item membership and member lists, use Host::tags(): resolve a TagKey with Tags::key, then call Tags::is_in_tag, Tags::items or Tags::fluids. Membership reads return None on clients until server-sent tags are applied, which tags_updated announces; they never substitute local resource-pack tags.
LevelHost has no random, by design: a draw from a read-only hook inside a level write would shift the level's random sequence away from vanilla's order. A hook that may draw is given a LevelRandomHost.
LevelRandomHost provides vanilla's Level.random so
mod draws come in vanilla's order. LevelTicksHost adds scheduling through
native_schedule_block_tick and native_schedule_fluid_tick, while
LevelWriteHost adds random access, set_block, set_block_property, native_play_sound and
native_send_particles and native_level_event (levelEvent, queued in order with the write's own events).
set_block(pos, state, flags) is Level.setBlock on a whole state, as LevelRef::set_block takes it: it
removes the old block's block entity and creates the new block's in the write (LevelChunk.setBlockState), runs
the neighbour and shape updates flags ask for, and queues the write's level events in order. A mod block entity
it creates starts with the data the block_entity hook gives, which is fired in the write and receives the level
(LevelSide, read-only: vanilla's newBlockEntity gets no level), as the Host's native_set_block fires it. It returns true when the block
changed, and false off an authoritative server, for a position that is not loaded or outside the build height,
an invalid state, or the state the block already has. It never loads a chunk. The game owns
the tick queue; mods do not pass it back to these helpers.
Frame drawing through DrawContext, BlockEntityDrawContext and WorldDrawContext stays
in mod_api. Raw GPU access is native-only, unstable and outside that API, in
mod_render::render (horizon-mod-render). Register its listed frame functions through
[[inject]] with access = "internals" and kind = "head"; the ordinary listener registry
owns ordering and disabling. render_needs collects requests; world_drawn runs before
the hand and HUD, and hand_drawn runs after the hand, on the render thread.
| Removed path or registration | Current route |
|---|---|
mod_api::render raw GPU types | mod_render::render (native escape hatch) |
RenderHooks / Hooks::render | [[inject]] internals listeners on the three listed frame functions |
| API draw contexts | Unchanged; use these for API-typed drawing |
The typed API from mixin code
An [[inject]] listener can use the typed API: an API point's listener gets the side's Host and calls
attachments and handles through it. The worked examples, and which shape to choose, are in
Injections; each mixin injector's form is in
From mixins and legacy kinds.
A catalog hook's trait method, event and return make up its hook_version contract
(Named hook points). These per-hook contract versions are catalog metadata, not minimum game
versions. Catalog hooks, Host calls and internals [[inject]] declarations all build against the
game's exact source revision. Rebuild the mod when that contract changes.
Every impl Hooks must have #[mod_api::horizon_mod]. The attribute derives
subscriptions from the methods implemented in that block, so dispatch knows them at installation.
Parts
A part is code a trait mod attaches to one block, block entity, item or mod menu type, its own or vanilla's
(the spec). A mod attaches its parts once, at startup, in
Hooks::register. Each part trait implementation must have #[mod_api::horizon_part]; the attribute
subscribes it only to the methods it implements. A BlockEntityPart also needs a separately annotated
BlockPart implementation:
#![allow(unused)] fn main() { struct Gauge; #[mod_api::horizon_part] impl BlockPart for Gauge { fn has_analog_output(&self, _: &dyn LevelHost, event: &mut HasAnalogOutputEvent) { event.output = true; } fn analog_output(&self, _: &dyn LevelHost, event: &mut AnalogOutputEvent) { event.output = 15; } } #[mod_api::horizon_mod] impl Hooks for MyMod { fn register(&self, r: &mut dyn Registrar, _: &RegistrationContext) { let stone = r.block_key("minecraft:stone"); r.block_part(stone, Gauge); } } }
Registry keys
On r: &mut dyn Registrar, r.block_key(id) and r.effect_key(id) request registry entries,
not new content. Registrar is the generated union of the kind-local registration traits
(such as BlocksRegistrar and EffectsRegistrar); mod registration hooks and the
horizon-machine-sample continue to
use dyn Registrar. Each kind owns its declarations and implementation in parts/<kind>.rs;
the union and WebAssembly bindings discover those traits without a shared method list.
The capability-provider lookup union is assembled the same way. Declare them in register, retain the returned BlockKey or EffectKey in the
mod or Part, and reuse them for registry operations. Requests are canonicalized (a bare id
uses minecraft:), deduplicated across mods and checked after every mod's content is known.
An unknown entry refuses the load with the requesting mod and canonical id, even if the
key was never attached to a Part. A later mod may supply the requested entry.
The completed block identity table includes every vanilla and mod block, even blocks no mod requested. Completion preserves requested slots and appends the remaining canonical names in sorted order. Names belong to the installed mod registry; world load and reload prepare a separate numeric mapping for every retained key before publishing it. A missing requested block refuses completion without publishing a partial table. Immutable block definitions (property names, legal values, state combinations and defaults) are published with those names before mesh tables are constructed. Workers share read-only definitions; property reads allocate nothing. Pack reload does not mutate definitions or identities. The world's numeric mappings and tag membership remain separate reloadable data.
Metadata contexts are read-only and borrow the same table on the host and on workers. Wire transport records are internal, not saved block values. Persist canonical identifiers and property strings rather than slots or state ordinals.
BlockState is a small owned value with private fields, not a tint-specific snapshot or an
engine numeric ID. state.block() reads its BlockKey; state.ordinal() reads its local
ordinal. Property operations take explicit metadata: retain let metadata = host.block_states()
and call state.property(&metadata, "facing") or state.with(&metadata, "facing", "east").
BlockState::of(&metadata, key, constraints) starts from the published default and applies the
supplied StateConstraints. Changes return a new value and never mutate the original.
Unknown keys, ordinals and property combinations return typed BlockStateErrors.
SceneBlock::new(pos, state, entity) and StructureTemplateBlock::new(pos, state, nbt)
use this owned BlockState, including all properties. Structure reads preserve vanilla palette
selection, mirror-then-rotation and placement order; an unavailable state mapping refuses the
whole snapshot with None. DrawContext::draw_scene checks the entire scene before drawing:
an unknown key, invalid ordinal or unavailable mapping draws nothing, never a partial scene or
a replacement air block. Native and wasm use the same values and refusal rules.
Tint callbacks receive the same immutable metadata resource explicitly, without a Host.
The game prepares their owned state array before constructing mesh tables; renderer workers
only copy those values and invoke metadata reads. When world numeric state IDs are reordered,
the game resolves each state's property combination against the published definition, so
local ordinals retain their meaning across reloads.
host.block_states().states_of(key) returns a BlockStateRange in that same immutable
local ordinal order. Its iterator yields owned BlockStates; it is not a span of world
numeric IDs. BlockStateRange::new(first, count) returns a typed error if the span would
exceed the u16 ordinal domain, and decoded transport spans obey the same check. Membership
checks both the block key and the ordinal. A zero-count span is empty.
A restricted LevelHost::block_state(pos) read returns Option<BlockState>. A successful
engine read retains its vanilla result: unloaded or out-of-height reads that produce air return
Some(air), using the owning registry's real air state. An unavailable owning state index or a
refused engine-to-API conversion returns None; the boundary never fabricates a key or air state
and never panics to satisfy a mandatory value. Other optional/result read contracts are unchanged;
in particular, this does not change LevelRef's loaded-position policy.
LevelRef::set_block, update_neighbors and neighbor_changed take owned block states
and return Result<bool, LevelWriteError>. Stale identifies a closed or reopened level;
Unavailable identifies the wrong side, phase or nonactive write target; InvalidState
retains the block-state refusal. Errors do not mutate the level. Ok(false) is still
vanilla refusal, such as an unloaded or out-of-height position or an unchanged block,
not a lost handle or a guessed replacement state. Update flags retain their vanilla effect.
Walking a whole registry uses the typed keys, never names: host.block_keys() and
host.item_keys() return every entry in the registry's registration order (vanilla's
entries first, then mods' in the order they registered); host.entity_type_keys(),
host.menu_keys() do the same for the built-in entity-type and menu registries.
host.enchantment_keys() uses global sorted loaded-registry order, with vanilla and mod
data-pack entries sorted together. A block registry lists a name once
per state, and its iteration lists the block once. Build lookup tables from these lists at
load time instead of resolving names per tick. A table is empty before its registry loads
(blocks and items during rest load, enchantments with the item tables); registration-time
RegistrationContext does not see any of them (#1001).
Keys are small, private u32 registration slots on both native and wasm. key.id(host)
returns an owned canonical String through the generated read backend for diagnostics; it
requires a game-backed host and an invalid slot is a boundary error, not an empty name.
Block defaults come from immutable API metadata and cross through the game's sole forward/reverse
state index. Behavior queries obtain their engine block from the resulting engine state; there is
no separate per-key numeric block binding. Reload preserves key identity and canonical names.
A refused resource rebind clears the active index, while failed transactional Parts preparation
leaves the previously published Parts and index intact. Effect registration is process-owned and fixed
until restart, matching the engine's effect registry.
A name that only arrives at run time (data, config, a command, a network message) turns into a key
with the host: host.block_key("minecraft:stone"), host.effect_key("mymod:rage") and
host.fluid_key("minecraft:water") return Option, and key.id(host) gives the name back for
blocks, effects, fluids and items. Names are full registry names; the lookup reads the completed
registry and does not allocate, so a hook can call it. It returns None for a name the registry does
not hold, and before the registry is complete, so a mod uses r.block_key(id) in register. These
work on the client and the server, for native and wasm mods alike.
Block tag membership is bound from the same merged item/block tag snapshot at world load and
reload, including worlds without pack overrides. A read checks that the owning state index is
available, looks up the tag name and tests membership in a prepared API BlockKey set; it never
resolves the block's name. A fully namespaced tag needs no allocation (a bare tag adds minecraft:).
Binding visits each merged block-tag member once and resolves its API key; it replaces
the previous snapshot rather than accumulating membership. No membership work is added to ticks
that do not call this read. Tags and block mappings belong to their resolving thread; missing
authoritative context or an unavailable mapping returns None, not a stale membership result.
host.tags().blocks(tag) reads the current authoritative block members in vanilla order as
BlockKey values, including blocks no mod requested. Resolve tag once with Tags::key and
retain it across reloads. Unknown or empty tags return Some(Vec::new()); an unavailable
context or any unresolved member returns None, never a partial member list. The same service
is available through restricted level Hosts, without borrowing an engine registry.
host.block_state_default(stone), host.block_states().states_of(stone),
host.block_item(stone), host.block_in_tag(stone, tag) and restricted-level
schedule_block_tick(pos, stone, delay) use BlockKey. Property expressions accepted by
block_state_id, tag names and existing fluid operation arguments remain strings for now.
Declare a tank-only fluid in your own namespace with r.declare_fluid("chocolate"); the returned FluidKey identifies it for the mod load. Request vanilla or another mod's declared fluid with r.fluid_key("minecraft:water") or r.fluid_key("other:chocolate"). Requests resolve after all registrations, so declaration order does not matter; an unknown request stops the load and names the requesting mod. Tank-only fluids have no world simulation or world state. Fluid stacks and recipe ingredients use these keys; remaining fluid operations move to them in subsequent changes.
Declare a fluid that exists in the world with let mut fluid = WorldFluid::default(); fluid.drop_off = 2; r.declare_world_fluid("oil", fluid);,
using mod_api::WorldFluid, the code form of a fluid file's world object (content mods): it adds the block
mymod:oil, the flowing fluid mymod:flowing_oil and the bucket mymod:oil_bucket, and its fluid file, if it has one,
still says how it looks. r.fluid_part("mymod:oil", Part) attaches a FluidPart, whose fluid_rules can change the
declared values by position on the server. It is asked for every cell a spread looks at, so answer fast.
A saved tank whose fluid's mod is absent carries an opaque unresolved identity. Its stack's
key() is None; UnresolvedFluid::id(host) reads its saved name. It can move between
name-backed mod tanks, but not into vanilla buckets or cauldrons.
ActiveEffect::id and effect hook payloads carry EffectKey; living effect queries,
application and removal use the same resolved key. ActiveEffect::new(speed, …)
uses a key requested through r.effect_key("minecraft:speed"). Beacon-selection hooks
carry Option<EffectKey> for each power; None is no selection. Enchantment effect-component
names (for example minecraft:damage in has_enchantment_effect) are a different registry
and remain strings, as do labels and tags.
Hot item and block identities
Resolve item and block keys during load and keep them for tick and menu calls.
item_burn_time, item_compost_chance, give, enchantment_supports's item,
and GUI item / item_model take ItemKey. block_item, fluid_bucket and
screen_hovered_item return item keys. level.block, lit_block_name and
Hit.block return block keys; BlockEntityDrawContext::block takes one.
HeldItemSlot.id (including use-start/stop events) and Stack.item
carry item keys, not names. Unknown incoming slots are refused at the boundary.
block_sprites takes API-owned BlockState values, preserving properties without
parsing state strings or exposing numeric engine state IDs. The pre-change block event carries resolved from / to
states and does not allocate their names. Property names and values in
set_block_property are not block identifiers and remain strings.
These calls remove string transport across wasm. Engine metadata reads may still hash borrowed registry names; no formatted identifier is built for these inputs.
Matched recipe lookup
level.recipe_for(host, &recipe_type, &inputs) replaces the crafting-only lookup.
Use RecipeInput::Crafting { width, height, slots } for minecraft:crafting
(row-major slots, dimensions 1 through 3), Single(stack) for smelting, blasting,
smoking, campfire cooking and stonecutting, or Smithing { template, base, addition }
for minecraft:smithing. Recipe types are not serializers: shaped and shapeless
files both belong to minecraft:crafting, and transform/trim both belong to smithing.
The server uses the same engine matchers and assembled outputs as its menus,
including data-pack overrides of vanilla recipe types. A RecipeMatch contains the
matched recipe identifier, typed output, ordered remaining items, experience and
processing time. Lookup consumes nothing; the caller applies inventory changes
transactionally. Keep the result until inputs or the recipe load change.
Ok(None) means valid input without a match. Invalid shapes/items and unavailable
levels are typed errors. A type without an engine matcher returns
RecipeLookupError::NotMatchable, not an empty match. For mod-owned types, retain
recipes_of_type once per load/reload and match that cached data inside the mod,
with zero crossings per tick. Dynamic custom outputs and contextual predicates
remain mod-owned; the host does not call guest code to match recipes.
At publication, call ingredient.item_keys(host) once per ingredient to retain
Arc<[ItemKey]> for local matching. This load-time read returns exactly the plain
item membership for lists, expanded tags and standard shapes, using the engine
matcher. It returns IngredientKeysError::NotEnumerable if any custom or component
predicate occurs anywhere in the tree; a displayed item list is not a safe substitute.
UnknownItem refuses a missing registry identity. Do not call this method per tick.
The sample's ProcessingRecipes::publish retains these keys; result then checks
input.item() locally, with no Ingredient::test host call per candidate.
Stack placement predicates and drawing consumers (HUD items, GUI item decorations, block-entity items and tooltip images) encode API stacks without enforcing resident count or damage maxima. Engine-legal over-limit stacks can still be inspected and drawn. Unknown item keys and invalid/unrepresentable effect payloads still refuse encoding; this is not a total conversion. Insertion and other world writes retain strict validation.
API-owned values: migration table
| Previous engine value | API-owned value | Mod changes |
|---|---|---|
Engine stack in TooltipDisplay::for_stack | mod_api::ItemStack | Pass the owned API stack supplied to the tooltip event. Its effective tooltip component includes vanilla's implicit defaults. Normal game rendering borrows the engine component without creating an API stack. |
| Engine loot context and candidate stack | mod_api::{LootContext, LootStack} | Contexts own their facts; candidate items are ItemKeys. Counts remain signed. Ordered Option<Tag> patches preserve removals and NBT types, and names are API Text trees. Writeback is atomic. |
Engine ParticleType and options | ParticleKey and API ParticleOptions | Retain a key requested through Registrar::particle_key. Every parameter-family constructor takes its key; simple(key) also replaces string-based custom provider construction. The engine enum is no longer exported. |
| Engine damage containers | mod_api::{DamageContainer, DamageValues, Reduction} | Hooks receive an owned scalar snapshot. Read reductions with reduction; edit damage and cooldown through the container setters. Sequencing methods remain game-only. |
| Engine item property values | mod_api::{ItemBar, ItemRarity} | Resource methods retain their signatures; damageability, glint and repair checks use game-owned metadata reads. |
Package manifest Side | mod_api::Side | Match Client and Both on the package metadata snapshot. This is not HostSide or the executing logical side. |
Command Suggestion | mod_api::Suggestion | Construct with Suggestion::new((start, end), text); optional tooltip and int_value remain writable. Offsets are byte offsets. |
| Package block and item declarations | mod_api::{Block, Item} | Use API-owned declaration records in the registration events. Component patches are ordered (String, Option<Tag>) entries, with None removing the component; the game converts once at load and applies content-file admission checks. |
| Package block entity type storage | mod_api::BlockEntityTypeDecl | Existing r.block_entity_type(...).build() registration is unchanged. The declaration snapshot no longer exposes the package parser's model; the game converts it once when declarations freeze. |
| Component protocol vocabulary | text::{TextContent, TextNbtSource, TextObject, TextClick, TextHover} | Match these enums exhaustively: their serialized vocabulary is closed. Records remain non_exhaustive. |
| Renderer GUI commands | mod_api::GuiDraw | DrawContext::gui_paint returns a typed text-conversion result; handle refusal before drawing. HUD hooks reject invalid writeback as a whole. |
| File-opening click actions | Not in text::TextClick | Mods can neither produce nor round-trip OpenFile; untrusted file-opening action metadata is dropped. Core tracks local/remote origin per node, including mixed trees; exporting JSON or TextForm and decoding it makes every node remote. Adding API OpenFile remains a separate vocabulary/link-contract change. |
mod_api::GameRuleValue owns the Bool(bool) and Int(i32) cases, in that order.
level.game_rule(host, id) returns Result<Option<GameRuleValue>, LevelReadError>:
Ok(None) means unknown rule, Stale means a gone/reopened level, and Unavailable
means the side or phase cannot read it. level.game_rules(host) returns the typed
result containing every available rule, not an optional list. The existing boolean
key getter is unchanged. The game converts values after the subscription guard for
game_rule_changed; no subscriber means no conversion work.
The API's GameRuleDecl records a mod's boolean rule name, category and default.
Its internal RuleSchema input describes vanilla categories and dense rule positions
through read-only lookups rather than a copied vanilla list or fixed slot numbers.
Schema validation resolves category references after all mods have declared them and
returns either all declaration slots or every refusal; it never installs a partial table.
The game supplies this schema at Parts installation, then builds each world's engine
rule registry from the installed GameRuleDecl snapshots. Existing rule registrar calls
are unchanged; engine registry construction is no longer exposed through the API.
The game owns package and completion conversion. Mods do not import package-loader or client-core types for these values. Completion edits are copied back after subscribed listeners run; no listener means no snapshot allocation. Package metadata is copied once at startup, retaining declared namespaces and dependencies.
Opaque NBT metadata
The shared JSON/NBT conversion is iterative and follows vanilla's 512 nested
list/compound limit; scalars and NBT typed arrays add no recursive container.
Conversion to JSON intentionally loses numeric tag widths, boolean-as-byte
identity and typed-array identity. NonFinite refuses NaN and infinities:
serde_json::Value cannot hold them, and Gson's strict JSON writer also refuses
them. InvalidString refuses an unpaired UTF-16 surrogate in a Tag string or
compound key. Neither case is replaced with null, a string or U+FFFD.
JSON input already uses UTF-8, so valid non-BMP pairs survive; a lone surrogate
cannot reach this converter through that input representation.
Owned component trees
The text module defines owned component data: literal and translatable contents,
style overrides, ordered children, selector and NBT sources, inline objects, and click
and hover actions. Translation keys and argument components stay unresolved until the
game displays them. None style fields inherit; Some(false) overrides an inherited
decoration. Metadata, dialog/custom click payloads and hover-item component patches are
owned Tag data, never JSON strings or engine state. TextProfile carries an optional
name and UUID, signed property records and opaque unknown profile/property fields.
Signed record order and unsigned values within each property name are preserved.
Player names use U+0021..U+007E and at most 16 characters. Vanilla's unsigned
property-map form allows at most 16 distinct names; signed or metadata-bearing
records use a 16-entry list, with UTF-16 limits of 64, 32767 and 1024 units for
property name, value and signature respectively.
Inline player and atlas objects use Option<Box<Text>> for their fallback: None
keeps the engine-computed default; Some preserves a styled or translatable component
rather than flattening it to a string.
The native boundary plans borrowed source topology before cloning payloads. It reads
source-less spans directly and validates every Tag's JSON representation before building
an engine component. Text::validate() checks tree/payload bounds and profile fields
without cloning; validate_depth() is the component-only preflight. NaN/infinity and
unpaired UTF-16 remain legal Tag data but cause typed NonFinite or InvalidString
refusals when engine emission requires JSON. Nested JSON null is refused as NestedNull
when capturing opaque NBT fields; it is never silently replaced.
Host methods accepting or returning Text return Result<_, TextFormError>; mixed
item/Text writes preserve the refusal in StackWriteError::Text, and unspawned-mob
reads use UnspawnedMobError::Text. TooDeep means a component path or opaque NBT
container path exceeds 512. Unavailable backends are distinct from invalid text or
unrepresentable payloads.
A refused hook capture does not dispatch; a refused writeback leaves every field unchanged.
No subscriber means no conversion or snapshot allocation.
Mutable stack arguments are compared using value equality with the snapshot passed to
the listener. An unchanged argument retains the original engine value without strict
write validation (including engine-legal stacks above effective count or damage maxima).
A changed argument is validated in full; a refusal still commits no outputs. The same
rule applies to anvil/grindstone records and menu cursor/quick-move stacks.
Recursive Clone, Eq and Drop of a tree a mod itself built are ordinary Rust ownership,
not part of the boundary guarantee.
Text::new(content) constructs any content kind without exposing an engine value.
The content, NBT-source, object, click and hover enums are exhaustive protocol enums;
new cases change the link contract, just as enum-case order does. Mod matches should
name every case rather than silently treating a new protocol case as an old one.
Typed content and style fields are authoritative; metadata retains unrecognized
properties, not overrides of those typed fields. Game-side encoding is fallible
and reuses the engine's translation-argument and fallback construction rules.
Capture keeps dynamic contents and metadata rather than flattening the rendered spans.
It shares the engine's SNBT flag, shadow-colour, click-policy and inline-object parsers.
File-opening metadata is discarded before a listener receives the owned value; a
no-op listener cannot promote an untrusted file action into a local action.
A translatable's fallback is a literal format template, not another language key.
A missing key whose fallback equals an existing key therefore still displays the
fallback text itself; parsing and native constructors follow the same rule.
Particle identities
ParticleKey is an opaque slot in the load-owned registry tables. One completed
snapshot retains both canonical names and provider identities: a vanilla provider's
numeric index or a custom named sprite provider. The load resolves each provider once;
reads of a retained key do not parse identifiers or scan the provider list. Unknown
forward references refuse publication of the entire particle snapshot. Name-table
completion shares the item registry's requested-slot preservation and sorted enumeration.
Placement and loot snapshots
mod_api::PlacementContext owns the placement inputs, including world-space click
coordinates and the placer view/flags. Its item is Option<ItemKey>: None is a
real empty hand, including falling-block placement contexts, not an invented air
item. Nonempty items resolve through the existing load-owned item table. The game
captures this read-only snapshot after the scoped subscription guard; no subscriber
means no snapshot or item lookup. The block-state answer is applied as before.
The snapshots module owns placement inputs and loot-roll facts. Placement and tool
items are resolved ItemKeys. Loot context entities, damage sources, tools, biome,
block and place facts are owned values; retaining one never retains a borrowed engine
context or a live level. A loot candidate is not an inventory ItemStack: its signed
count can be non-positive while loot functions run, in which case it produces no item.
Exploration searches remain deferred data, and name components remain unresolved text.
Item values and tag names
Every item has an ItemKey, including vanilla items no mod requested during registration.
r.item_key(id) retains a forward reference in the same load-owned table; after the item
registry is complete, host.item_key(&identifier) resolves any registered item. A key is
still a private u32 index, not a copy of an item definition. Resolve once and retain it.
host.max_stack_size(key) and host.max_damage(key) read item defaults without exposing
the engine's item table.
ItemStack is owned API data with private fields. ItemStack::new(key, count) refuses
zero, while ItemStack::of(key) makes one item. Count mutation refuses zero and arithmetic
overflow. Registry-dependent maximum count and damage, including component overrides,
are checked by the game when accepting a write; an oversized value is refused with a typed
error, never silently clamped. This is the same boundary on native and wasm. Consuming
split(n) returns StackSplit { taken, remainder }, both optional; consuming shrink(n)
returns the remainder or None. There is no empty-stack sentinel. Component descriptors
read and write an optional owned NBT patch; a plain stack has no patch allocation.
host.tags() and a restricted level host's tags() expose the same Tags service. Resolve
a validated identifier once with tags.key(&identifier) and retain its TagKey. The runtime
owns tag-name slots for the process; reload replaces membership, not names or key identity.
tags.is_in_tag(item_key, tag_key) tests merged item membership. tags.items(tag_key)
and tags.fluids(tag_key) return registered keys in vanilla's member order. Unknown tags
return false or empty lists. Membership reads return None on clients, before authoritative
tags load, or if the relevant registry mapping is unavailable. Key resolution itself can be
refused if the runtime cannot allocate a name slot. No lookup stringifies a retained key.
Block member enumeration moves with the registry-wide BlockKey table in the next type slice.
The engine TagRegistry, StackData, StackView and DisplayContext are not API types.
A hook whose catalog entry has a scope (block, block_entity, item, menu) and a key, the argument
naming the thing it is about, is a method on that scope's trait only: BlockPart, BlockEntityPart (a
BlockPart too), ItemPart and MenuPart, as vanilla's and NeoForge's Block and Item methods are. It has
no Hooks method: a thing without parts is never asked, and the game asks the parts attached to it in dispatch
order, through the hook's contract, so a Cancel or a replacement skips the parts after it. Where a NeoForge
bus event is the counterpart for that moment, the event is a hook of its own on Hooks, asked first for every thing, and its cancel
skips the parts: a mod reacting to its own blocks uses a part, one reacting to every block the event. A part's kind is its type: the same kind twice on one
thing, an invalid key declaration, or an unnamespaced id in the other Part registrations stops
the game at startup with a message naming the mods. A panic in a part
adds , part <type> to the mod and hook it names. Parts take &self and are Send + Sync, like Hooks.
Damage and item metadata snapshots
The three damage hooks convert only after their subscription guard. DamageValues
retains the entire sequence, including reductions and the pre-hook input, so changing
new_damage still reaches the engine's original NaN and negative normalization point.
DamageValues::set_reduction edits snapshot data; it does not run a damage calculation.
The fixed reduction storage is private; use its accessors rather than an array index.
Reduction case order is append-only. Engine record_*, enter_pre, capture_inflicted
and pre_change are not mod API methods.
ItemResource::damageable, foil and repairable delegate to the game's existing
property readers through metadata access. Defaults, explicit overrides, component
removals, compass glint and repair tags follow gameplay's single property path.
Recipe-file fields and small values
Host::recipe_ingredient and Host::recipe_result take the field's JSON text,
not a serde_json::Value. Pass a quoted item id (r#""iron_ingot""#), a quoted
#tag, or an ingredient array. Parse failures return RecipeJsonError::InvalidJson;
unknown items and empty tags still return Ok(None). Result objects retain their
components patch. These helpers are reload-time reads, not per-tick JSON parsers.
| Former engine value | API-owned value |
|---|---|
mod_list::KeyConflictContext | mod_api::KeyConflictContext |
firework::FireworkShape | mod_api::FireworkShape |
manifest::JarInputsError | mod_api::JarInputsError, with owned diagnostics and original-jar identity |
Recipe helper &serde_json::Value | JSON &str, returning Result<Option<_>, RecipeJsonError> |
Block Parts
BlockPart::clone_item_stack answers on the server when a player picks the block. It receives the
level, position, block state and include_data, true when a creative player picks with Ctrl. Return
None to keep the game's item.
BlockPart::fall_on runs at vanilla's landing moment on a read-only level random host.
Request a deferred write with event.request(LandingWrite::SetBlock(state)), where state
is an API BlockState, or LandingWrite::DestroyBlock { drop }. The game checks the
complete write batch before queuing it. An invalid block key or ordinal refuses that
callback's writes and other outputs, including damage; it never applies a valid prefix.
After the entity batch ticks, writes apply in request order if the block still matches
its original landed state. That guard runs once, so a first write can change the state
without suppressing the next write. SetBlock notifies neighbors and clients; DestroyBlock
preserves the requested resource-drop flag.
BlockPart::contents_changed(host, event) observes a world block's storage at setChanged,
with a ServerHost, typed event.level, event.pos: BlockPos and event.state. Attach it to the specific
blocks you observe, including vanilla containers; it has no global Hooks counterpart. The backing
contents are already written, before dirty/comparator updates. Native transactions notify once per
backing block at root commit, never on abort or simulation. An explicit BlockEntityRef::set_changed
notifies even if contents are unchanged. A double container notifies each component, not once again
for the other half's comparator refresh. A listener's writes still apply, but a nested notice for that
same backing block does not re-enter the listener; writes to a different backing block notify normally.
Entity/menu placeholder coordinates are not block notices.
This replaces Create's positional contents_changed mixin for depot/package/schematic storage; there is no
NeoForge bus event. A compiled listener that reads the committed items through the typed level (the game's
own test, so it cannot drift):
#[derive(Clone, Debug, PartialEq, Eq)]
struct Notice {
level: LevelRef,
pos: [i32; 3],
amount: u32,
version: Option<i32>,
}
#[derive(Clone, Default)]
struct Changes(Arc<Mutex<Vec<Notice>>>);
#[mod_api::horizon_part]
impl BlockPart for Changes {
fn contents_changed(&self, host: &mut dyn ServerHost, event: &mut ContentsChangedEvent) {
let pos = [event.pos.x, event.pos.y, event.pos.z];
let amount = event
.level
.capability(host, &ITEMS, pos, None)
.map_or(0, |items| {
(0..items.size(host))
.map(|slot| items.amount(host, slot))
.sum()
});
let version = host
.block_entity(event.level, pos)
.and_then(|entity| entity.data(host))
.and_then(|data| match data.get(&"Version".into()) {
Some(Tag::Int(value)) => Some(*value),
_ => None,
});
self.0.lock().unwrap().push(Notice {
level: event.level,
pos,
amount,
version,
});
}
}
Block entity types
Declare a block entity type and its valid blocks during Hooks::register, then attach its part:
let press = r.block_entity_type("press")
.blocks(&["mymod:press", "mymod:big_press"])
.build();
r.block_entity_part(&press, Press).expect("native block entity type");
The builder qualifies the type ID with the registering mod's namespace. It owns the save ID, wire type
and valid blocks; block_entity returns only initial Compound data (or none for an empty compound). It is fired
inside the write that creates the entity (LevelChunk.setBlockState) and receives the read-only level, a LevelSide,
on a server and on a client of one.
Unknown blocks, duplicate types, overlapping membership and attempts to own a minecraft: block are
refused at load. Refused native block, item or block entity declarations stop startup, as does unreadable
known-components input; the game does not fall back to vanilla content. An explicitly declared mod
template block uses its declared type; undeclared template blocks retain their vanilla behavior. Adding mod blocks to vanilla types is not supported by this builder.
The hooks about the entity (block_entity_tick, block_entity_update_tag, pre_remove_side_effects,
menu_button, menu_quick_move) find it by that declared type; the others (block_entity, screen, the
block hooks) by block. The factory is a BlockEntityPart method, not a global Hooks method.
r.block_entity_type_key("other:press") looks up another mod's type, and
r.block_entity_type_key("minecraft:chest") looks up a vanilla type. Both return the same opaque
BlockEntityTypeKey as the declaration builder: equality and hashing use the shared load-owned
registration slot, not the type's blocks. Bare names use the minecraft: namespace. The final
registry resolves requests after all declarations and refuses unknown IDs, naming the requesting mod
and type. key.id(host) reads the canonical name for diagnostics; a key retains its identity until
restart. Use a type ID, not a block ID (minecraft:shulker_box, not minecraft:red_shulker_box). BlockEntityRef::kind(host) returns Option<BlockEntityTypeKey>.
living.hurt_and_break(host, slot, amount) changes equipped durability on the server; negative amounts repair. It returns the pre-shrink stack only on break, and None when nothing breaks or the entity or slot is unavailable. Creative players' equipment is exempt. The operation does not fire equip effects.
block_entity.replace_data(host, data) replaces its saved payload, clearing omitted fields while retaining
identity and position. Like merge_data, it loads transactionally and synchronizes the change; it returns
Err(BlockEntityError::Stale) for an invalidated handle, or Ok(false) for data the entity cannot
load or an unchanged result.
Lookup keys may answer capabilities for vanilla and native types. block_entity_part accepts only
native types and returns BlockEntityPartError::VanillaType immediately for a vanilla key. Native
Parts are expanded after every mod has registered, so a Part can target a type declared later.
Item and tag Parts
For an item-specific Part, request its canonical registry key once in register:
let stick = r.item_key("minecraft:stick"); r.item_part(stick, Sharp);. Bare names use the
minecraft: namespace. Requests may refer to content declared later; the final item registry refuses
unknown IDs with the requesting mod. ItemKey equality uses its load-owned slot, and key.id(host)
reads its canonical diagnostic name. Item capability registration and item helpers still use strings;
their migration is a separate change.
r.block_part_tag("minecraft:logs", Rotates) and r.item_part_tag("minecraft:axes", Sharp) attach a part to
every member of a tag (its name without #). Each game resolves them against its own tags when its world
opens, again on /reload, and when a client joins a server, so the window's game and its server thread each
answer from the tags they play with; a block that leaves the tag is no longer asked. A mod's parts on one
thing run in the order it registered them, by key or by tag. A part a tag gives a thing
that already has that kind of part warns, naming both mods, and the earlier mod's part stays. A tag that
doesn't exist warns. Opening a world fails, naming the mod, when a part is attached to a block or item the game
doesn't have. Scoped hooks run on a game's thread; one dispatched from any other thread (which no game hook
does) can't see tag parts, and warns once.
Instant item use
ItemPart::use_item receives UseItemEvent { stack, hand }. hand: Hand is the
interaction hand of this click (Hand::Main or Hand::Off), including air clicks
with identical stacks in both hands. Read it directly when choosing which held stack
to shrink. living_used_item_hand instead answers only during active item use and
cannot identify an instantaneous click. The Part runs on both sides with HostSide,
after the global right-click event and before the item's own use, on native and wasm.
Return Some(InteractionResult::Success) to consume and swing on the client,
Some(SuccessServer) to consume and swing on the server, or Some(Consume)
to consume without a swing. Some(Fail) skips this item's own use without
swinging and allows the off-hand sequence to proceed; unlike a block-use Fail,
it does not end the entire click.
None (the default) lets later Parts answer; if none answers, the item's own use
runs. Some(Pass) stops dispatch to later Parts and continues directly to the
item's own use.
TryWithEmptyHand belongs to block use and is treated as Pass here.
A thrown item can return Success on the client and Consume on the server.
The hand field corresponds to NeoForge's
PlayerInteractEvent.RightClickItem.getHand().
Hooks::right_click_item runs before Item.use, including after the same hand's
unhandled block or entity interaction. A block success or FAIL, or an entity success, stops fallback.
The server fires it for the separate use-item packet, once for that packet's hand.
Canceling skips the item Part and vanilla action on native and wasm alike.
Item wear
ItemPart::damage_item is IItemExtension.damageItem: the wear a stack of the part's item is about to take
(ItemStack.hurtAndBreak), after Unbreaking, as NeoForge passes it. The part reads event.amount, the points
about to be lost, and changes it (0 or less leaves the item whole) or returns Flow::Cancel for no wear at all. It
runs on every wear path: a player's tools, armour, shield and mace, a tool a dispenser uses, and a mob's armour. It is
not asked when Unbreaking already saved the item or for a creative player's tools, and an item with no part costs
one table lookup. With no part, the vanilla amount applies.
The global item_damaged hook is gone: this method is its one moment, and a part acts only on its own item. What the
global hook could do and a part cannot is change the wear of another mod's or vanilla's items, which no part is
attached to. Do that with an enchantment effect in data (a minecraft:item_damage effect on the enchantment, the
component Unbreaking uses), or with an injection. A part does not get the wearing entity;
host.players().acting_player() names the player on the paths a player takes.
Capability providers
A capability is answered by providers, registered by block entity type as NeoForge's
RegisterCapabilitiesEvent registers them: obtain pipe with the declaration builder or
r.block_entity_type_key("mymod:pipe"), then r.block_entity_capability(&ITEMS, &pipe, |host, at| Some(Box::new(PipeItems))) gives a handler for the block entity and side at (a BlockSide), or none to
leave it to the next provider. A provider may answer for a vanilla block entity type too, using its lookup key.
Providers add up: level.capability(host, &ITEMS, pos, side) (Level.getCapability, on a LevelRef) asks those registered
with r.block_entity_capability_before_game first, then the game's own (a block entity's slots by side, by the
rules hoppers follow, and a mod block entity's block_entity_container), then the others, each in registration
order, and the first to answer wins. That order is the fork's choice, the game first and _before_game to go ahead
of it: NeoForge 26.3's javadoc doesn't state one between mods' providers and the game's. It returns a Capability, the handler and who answered. A handler holds
no borrow: each call is given the host, so it may call a
neighbour's capability, and it keeps its state in the block entity's slots, compound or attachments. A provider
is given the block entity and side, so the handler it makes knows where its contents live. Item handlers are
NeoForge 26.3's ResourceHandler<ItemResource>: indices from 0 (size), what each holds (resource, an
ItemResource: an item and its components, without a count) and how much (amount), how much of a resource an
index holds when it holds that (capacity, none where it may not go), and insert and extract, which take a
&Transaction and answer how much moved. There is no simulate: a write happens in a transaction
(host.transaction(|host, tx| …), below), and to try one, abort it. move_resources(host, &from, &to, filter, max, tx) (NeoForge's ResourceHandlerUtil.move) moves what filter accepts, up to max, index by index out of from
into wherever to takes it, each move all or nothing: what to won't take stays put. It answers how much moved, or
Overanswered (below). Hoppers use a mod's handler
where one is registered, each move in a transaction of its own. A mod's own capability is a
CapabilityKey::new("mymod:chemicals"), answered only by parts.
A Capability may be held for the tick: look a neighbour's up once per tick and reuse it for
every move that tick, as pipes and machines do. Ask again on the next tick, since the block
entity behind it may be gone by then. For a wasm mod each lookup crosses the sandbox (about
2.5 µs, see Building a mod to mod.wasm), so holding it matters more there.
Item places and components
An item's own capability is reached where its stack lives, an ItemPlace: a player's slot, hand or cursor, a
block entity's slot, an item entity, a stack inside another item (ItemPlace::Nested) or a mod's own place
(ItemPlace::Mod, read and written by the PlaceProvider it registers with r.place_provider).
r.item_capability(&ITEM_ITEMS, "mymod:backpack", |host, place| Some(Box::new(Backpack))) answers
ITEM_ITEMS for an item with a ResourceHandler<ItemResource> made for that place, and
r.item_capability_before_game ahead of the game's own over item_container;
place.capability(host, &ITEM_ITEMS) (ItemAccess.getCapability) asks them in the same order. A handler keeps its contents in the
stack, and reads and writes it through the place, as NeoForge 26.3's ItemAccess: place.stack(host),
place.resource(host) and place.amount(host), and place.insert, place.extract and place.exchange(host, tx, &resource, amount), which answer how much moved. Each runs in a transaction of its own, nested in tx, and changes
the place where it is, journaled, so an abort puts it back at every level of an item inside an item. A handler
answering more than it was asked or offered is a mod bug: the move it was part of is undone, and the game warns
once, naming the mod. What doesn't stack with the place, or doesn't fit, goes where vanilla puts an item split off
it, so exchange(host, tx, &filled, 1) on a stack of empty buckets is vanilla's filled bucket: from a player's
place into their inventory, else dropped with no thrower (a creative player's hand takes anything and gives them
one if they have none, as forInfiniteMaterials); from an item entity dropped beside it; from a block entity's,
an entity's or an item's slot into that container's other slots (forHandlerIndex). A handler rewrites its item
with an exchange of one. An exchange inside one of two backpacks is done in one of them: that one is rewritten
holding the change and splits off, and the other stays as it was.
A stack's components change the same way, where it lives (NeoForge's ItemResource.with and without). A component
is named by a ComponentId, checked once as a namespaced id (ComponentId::new("minecraft:damage"), None for
Damage or !minecraft:food). resource.component(&id) reads the stack's own value in its patch as saved;
resource.with_component(host, &id, value) sets it as vanilla's PatchedDataComponentMap.set does, keeping no value
of the stack's own where value is the item's default (host.item_default_component(item, id), compared as the component's codec reads it: a vanilla
component's numbers by exact value, custom_data and the other raw NBT components and a mod's own type for type), and resource.without_component(host, &id) removes the component as remove does: the stack's own value
goes, and where the item has the component by default a removal ("!minecraft:max_damage": {}) is recorded in the
patch.
An exchange of one puts the changed item in place: place.exchange(host, tx, &place.resource(host)?.with_component(host, &damage, Tag::Int(5)), 1) damages one sword of a stack, splitting it off.
An entity's items
entity.capability(host, &ENTITY_ITEMS, ()) (NeoForge 26.3's Capabilities.Item.ENTITY) is an entity's items, None for a
stale handle and an entity outside the level in place. The game answers it for a player with their inventory
(PlayerInventoryWrapper), indices as vanilla's Inventory numbers them: 0..36 the main inventory (0..9 the hotbar),
36..40 the armour from feet to head, 40 the off hand. Each index is the player's ItemPlace::PlayerSlot and takes only
what fits there: an armour slot one of what its equippable names it for (ArmorSlot: its allowed entities hold the
player by id or by tag), and bound armour comes off only in creative. insert_any, without an index, goes where
Inventory.add puts a stack: onto the same item in the selected slot, the off hand and the main inventory in order,
then into its empty slots, never the armour. For a chest or hopper minecart, a chest boat and a chested donkey, mule or
llama the game answers with their container's slots (ItemPlace::EntitySlot). A handler kept past its entity's removal
or its world's close reaches nothing. r.entity_capability(&ENTITY_ITEMS, "mymod:robot", |host, robot, ()| …) answers it for a mod's entity type, and r.entity_capability_before_game ahead of the
game's own, asked as block entities' providers are. Giving, taking and counting a player's items go through it:
#![allow(unused)] fn main() { // A trade: each offer is (index, the exact stack it must still hold). A stale offer or no // room for one aborts the transaction, and every move in it is put back. let (a, b) = (seller.capability(host, &ENTITY_ITEMS, ())?, buyer.capability(host, &ENTITY_ITEMS, ())?); host.transaction(|host, tx| { for (from, to, offer) in [(&a, &b, &seller_offer), (&b, &a, &buyer_offer)] { for (index, stack) in offer { let offered = ItemResource::of(stack); let still = from.resource(host, *index).as_ref() == Some(&offered) && from.amount(host, *index) == stack.count(); if !still || from.extract(host, tx, *index, &offered, stack.count()) != stack.count() || to.insert_any(host, tx, &offered, stack.count()) != stack.count() { return Err(()); } } } Ok(()) }) }
Cauldron interactions outside a transaction keep vanilla's effect order. Inside an open
transaction, their statistic award (including player_stat_award), sound and overflow
item toss/spawn wait until the outermost successful commit; an abort emits none of them.
Entity energy and fluids
entity.capability(host, &ENTITY_ENERGY, side) and entity.capability(host, &ENTITY_FLUIDS, side) reach a mod's
entity energy and fluid handlers (NeoForge 26.3's Capabilities.Energy.ENTITY and Capabilities.Fluid.ENTITY), where
side is the Option<Direction> of the entity the lookup reaches it from, None for no particular side. Vanilla
answers neither; register a provider with r.entity_capability(&ENTITY_ENERGY, "mymod:robot", |host, robot, side| …),
which is given that side and may answer differently for each. An entity capability's context is its key's second type:
() for ENTITY_ITEMS, Option<Direction> for these two and for ENTITY_AUTOMATION. The existing move_energy and move_resources transfer
between these and block or item handlers inside a transaction.
Entity automation
entity.capability(host, &ENTITY_AUTOMATION, side) (NeoForge 26.3's Capabilities.Item.ENTITY_AUTOMATION, "automation-accessible
inventory of an entity (boats, minecarts, etc)") is the items of an entity as a hopper reaches them, with the same
Option<Direction> context as the energy and fluid keys. The game answers it for the container entities
EntitySelector.CONTAINER_ENTITY_SELECTOR picks (chest and hopper minecarts, chest boats) with the slots their
ENTITY_ITEMS handler has; a player and a chested donkey, mule or llama answer none. A hopper
pushing into the entity in front of it asks for the side it faces the entity from (Up for a hopper over it), a hopper
pulling from the entity above it for Down, and a dropper for the side it drops onto. Beside the game's container
entities, those entities at the target whose mod provider answers it join the candidates, and the hopper picks one at
random with the draw getEntityContainer makes; a mod handler takes the container's place and moves one item at a time
in a transaction, as a mod block's ITEMS does. r.entity_capability(&ENTITY_AUTOMATION, "mymod:robot", |host, robot, side| …)
registers one, and r.entity_capability_before_game answers ahead of the game's own for a minecart or a boat.
A menu's slots
Mod-created recipe input sets
Hooks::recipe_property_sets runs once after recipes resolve for each load and again after
/reload. Add RecipePropertyAddition::new("mymod:cooking_pot_input", items) to its
event.additions: a key in the listener's manifest-owned namespaces creates a set. Ownership
uses the package ID plus the manifest's namespaces, so a package named
ported_farmersdelight with namespaces = ["farmersdelight"] can create
farmersdelight:cooking_pot_input. A later listener may extend an existing set but cannot
create a new foreign key. Invalid keys, unknown vanilla keys and air are skipped with a
warning; unregistered items do not participate in acceptance or network sync.
Attach a MenuPart to the registered menu type and filter its input slot with the same set:
struct PotInput;
#[mod_api::horizon_part]
impl mod_api::MenuPart for PotInput {
fn menu_slot_rules(
&self,
context: &dyn mod_api::LoopContext,
event: &mut mod_api::MenuSlotRulesEvent<'_>,
) {
if event.slot == 0 {
event.may_place &= context.mod_recipe_property_contains(
"farmersdelight:cooking_pot_input", event.stack.item(),
);
}
}
}
// In Hooks::register:
// registrar.menu_part("farmersdelight:cooking_pot", PotInput);
The Host exposes the same mod_recipe_property_contains(set, ItemKey) query outside
restricted menu callbacks. This query covers mod-created sets only: unknown sets, unknown
items and vanilla set keys return false. Existing vanilla station acceptance is unchanged.
The server reads its owning load; the client reads local preload sets before its first
UpdateRecipes, then the authoritative server generation. A packet replaces the entire
generation, including an empty one. Closing a screen or changing dimension retains it;
disconnecting discards it. Reads allocate nothing and do not rescan recipes or registries.
There is no second hook and no per-slot load callback.
Client hooks can read the local player's menu with host.local_player()?.container_menu(host),
including the inventory when no container is open. MenuRef::slot_count, slot and carried
read on either side; slot and carried return an outer None when the handle or slot is
unavailable and Some(None) for an empty slot. Each read checks the world epoch and menu generation,
so a handle cannot read a replacement, reopened or respawned menu. These methods do not click or
change slots.
Mutable client hooks can submit a vanilla click with
menu.click(host, &[mod_api::MenuClick::QuickMove { slot }]). The slot is the same menu-order
index used by slot, not a hotbar index or a GUI slot id. For example:
/// Shift-click a slot in the local player's current menu.
pub fn quick_move(host: &mut dyn mod_api::ClientHost, slot: usize) -> bool {
let Some(menu) = host
.local_player()
.and_then(|player| player.container_menu(host))
else {
return false;
};
menu.click(host, &[mod_api::MenuClick::QuickMove { slot }])
}
The typed actions are Pickup (with MouseButton::Left or Right), QuickMove,
Swap (with SwapWith::Hotbar(0..=8) or Offhand), Clone, Throw,
Drag and PickupAll. Pickup accepts None for outside, dropping the carried stack.
A Drag { kind, slots } owns its menu-order slot list and always submits a complete gesture;
DragKind::Split divides evenly, One places one per slot and Clone fills slots in creative mode.
Every action in a batch is validated before any prediction or sending; out-of-range slots/hotbar
indices, creative-only actions in survival and empty batches refuse without effects.
Stale/replaced/respawned menus, spectators, non-client hosts and the creative inventory screen refuse with false.
true means submitted, not server acceptance: prediction uses the normal menu engine and sends
ContainerClick with its state id, changed slots and carried item. Server corrections still win.
Inventory reads and direct server-side item transactions keep their existing contracts; a click does
not expose direct client inventory writes or enforce a mod's locked-slot policy. Call from an explicit
input action rather than submitting every render/tick. Diverges from upstream: new helper.
menu.close(host) closes the menu's screen as Escape does and tells the server (ContainerClose);
it returns false while that screen is not open, so the inventory menu closes only while the inventory
screen shows. menu.click_button(host, button) presses one of the open menu's buttons
(ContainerButtonClick, as a loom's pattern list does), for input the screen's own
buttons don't take; the server's menu decides what it does, and the inventory menu has none.
ItemPlace::MenuSlot { menu, slot } is slot slot of a player's open menu, a MenuRef (player.container_menu(host)). It is
a fork addition: NeoForge 26.3's ItemAccess reaches a player's inventory slot (forPlayerSlot) and cursor
(forPlayerCursor), not a menu's slot. It is the storage the slot shows, named by menu.slot_backing(host, slot)
(MenuSlotBacking), and a write goes to that storage through its own handler, so it is kept once by the transaction
and the storage's rules hold: the player's inventory, armour and off hand by their
ENTITY_ITEMS index, a chest's or a furnace's slot through its ITEMS handler, a chest minecart's
or a chested mount's through its ENTITY_ITEMS, and the player's ender chest, inventory crafting grid and a mod's chest
menu (open_menu), kept by the game. The slot's own rules come first, as a click has them:
menu.slot_holds(host, slot, &stack) is how many of a stack it holds (Slot.mayPlace, then Slot.getMaxStackSize: a
furnace's fuel slot one bucket, an armour slot one piece that fits it, a shulker box's slot no shulker box, a result
slot none), and menu.slot_gives(host, slot) whether it gives up what it holds (Slot.mayPickup: bound armour stays
on outside creative). An insert fills that slot alone, and what doesn't fit stays out. A crafting grid a transaction
changed has its result made again at the outermost commit, once for each player however many writes it took
(InventoryMenu.slotsChanged, with native_prepare_item_craft); the result slot takes nothing and gives nothing,
since taking it is a craft. A menu closed, replaced or reopened since the MenuRef was made, or one whose player left,
reads and writes nothing. menu.cursor() is that menu's cursor (ItemPlace::PlayerCursor { menu },
forPlayerCursor), which reaches nothing once the menu is gone, so a kept cursor never reaches the next menu's. The player's screen follows with the tick's container sync.
#![allow(unused)] fn main() { // Put four of the menu's offered emeralds into slot 0 of the open chest, or nothing. let (slot, cursor) = (ItemPlace::MenuSlot { menu, slot: 0 }, menu.cursor()); host.transaction(|host, tx| { let took = cursor.extract(host, tx, &emerald, 4); if took == 4 && slot.insert(host, tx, &emerald, 4) == 4 { Ok(()) } else { Err(()) } }) }
A block's fluids
Fluids are the capability FLUIDS (NeoForge's Capabilities.Fluid.BLOCK), answered as items are, by a
ResourceHandler<FluidResource> in millibuckets. A FluidResource is a fluid id and its data components, without
an amount, kept as an item stack's are (a potion fluid's contents; water and lava have none), and a tank fills only
from empty or with the same fluid and the same components. A FluidStack is one with an amount, as tanks keep it
(resource.to_stack(amount), FluidResource::of(&stack)). The game's own answer is a mod block's tanks by side
(block_entity_fluids) and a cauldron, one tank of 1000 mB that holds water in thirds and lava only whole; a
cauldron has no block entity and is answered by position, and a powder snow cauldron holds no fluid. A cauldron
takes water and lava whatever their components and discards them, so
what comes out of it is plain: the one place a fluid's components are dropped. A mod block's saved tanks take no
fluid with components. level.capability(host, &FLUIDS, pos, side) finds a handler in a LevelRef's level, as
ITEMS' are found, and move_resources moves fluids as it moves items. A move the target takes only part of is
undone and tried again with that part, so a cauldron, which gives whole thirds and never more than asked, gives
what fits: asked for a bucket with room for 500 mB, it gives one level, 334 mB.
Fluid held by an item is ITEM_FLUIDS (NeoForge's Capabilities.Fluid.ITEM), found where its stack lives, as
ITEM_ITEMS is: place.capability(host, &ITEM_FLUIDS) gives a ResourceHandler<FluidResource> for the item at
that ItemPlace, and a write rewrites the item there by an exchange of one, so from a stack of more than one the
changed item splits off (r.item_capability(&ITEM_FLUIDS, "mymod:canister", …) answers it for a mod's item). The
game answers it for vanilla's buckets: one tank of 1000 mB, filled and
drained whole, an empty bucket taking plain water or lava and becoming its full bucket, a full one becoming an empty
bucket. move_resources moves between an item and a block as between two blocks: a full cauldron fills a bucket in a
player's slot, and two thirds of one fill none.
A block's energy
Energy is the capability ENERGY (NeoForge's Capabilities.Energy.BLOCK), answered by an EnergyHandler: an
amount and a capacity, with no resource, and insert and extract written in a transaction as a
ResourceHandler's are. r.block_entity_capability(&ENERGY, &battery, …) (with a declared or looked-up
type key) registers a provider, asked as ITEMS' are, and level.capability(host, &ENERGY, pos, side) finds one. The game's own answer is a mod block
entity's block_entity_energy buffer, as NeoForge's SimpleEnergyHandler: a fill through the sides it comes
through, up to the buffer's room and its limit per insert, and a drain by the sides it leaves by, up to the limit per
extract; a side that reaches neither finds no handler. move_energy(host, &from, &to, max, tx) moves between two
(EnergyHandlerUtil.move) as one step of move_resources: all or nothing, a move the target takes only part of
tried again with that part.
Energy held by an item is ITEM_ENERGY (NeoForge's Capabilities.Energy.ITEM), found where its stack lives with
place.capability(host, &ITEM_ENERGY), as ITEM_FLUIDS is, and moved by move_energy as a block's is. A mod
answers it for its item with r.item_capability(&ITEM_ENERGY, "mymod:cell", …), keeping the energy in the item's
own stack and rewriting it by an exchange of one, so from a stack of more than one the charged item splits off.
Vanilla has no energy items, so the game answers it for none.
Capability answer limits
The game never trusts an answer past what it asked or offered. A handler answering more than it was asked has its
move undone, in its transaction, and is warned once naming the mod, and move_resources or move_energy returns
Overanswered naming it. Returned from the closure of host.transaction with ?, it aborts the whole move, so
neither end keeps any of it: a broken handler can't make a move overflow, duplicate or lose items, fluid or energy, or
panic the game.
Consumable effects
A LivingRef on the server can apply one effect from an item's loaded consumable with
living.apply_consume_effect(host, &stack, index). The index is zero-based in component
order; this does not eat food, shrink the stack or run consume triggers. Some(true) or
Some(false) is the effect's result; None means the item, index or target is unavailable
or the effect is unsupported. Mob random teleport, armor stands and dragons are not
supported. Player random teleport uses the level random as normal.
Mod-defined kinds dispatch through the ordinary consume_effect hook. For these
kinds, Some(true) means dispatch was entered, not that a listener changed game
state; an unclaimed kind still does nothing. Reapplying the same kind from inside
its own handler on the same call stack returns Some(false) without recursion.
Another kind, including one owned by another mod, dispatches normally, and a later
independent call can apply the original kind again. This deliberately reverses
#550's original blanket exclusion of mod-defined kinds; it is not a second consume
hook or an alternate dispatch path.
Transactions
On a ServerHost, import HostTransaction to call host.transaction(|host, tx| …) (NeoForge 26.3's Transaction). It runs a closure whose Ok commits and whose
Err aborts, nested in the open transaction if any. tx, a &Transaction (NeoForge's TransactionContext), is
what every handler and place write takes, so none happens outside one. Inside, a capability write applies at once, so later steps see it,
and the game keeps each storage as it was before the transaction first wrote it: a block entity's item slots, saved
compound and attachments, a hopper's cooldown, a cauldron's block state, a player's inventory (slots, armour, off
hand and cursor, whether the player is the host or a guest), an entity's inventory (a chest or hopper minecart,
a chest boat, a chested donkey, mule or llama) and an item entity's stack (one emptied stays, empty, until the
outermost commit removes it). A nested commit hands its changes to
the transaction around it, and only the outermost commit makes them final. Then the world is told of each change
once, as SnapshotJournal.onRootCommit: setChanged and comparators, a jukebox starting or stopping, a cauldron's
block update, and an item dropped at a block, thrown by a player (what a filled bucket's split no longer fits) or
by an entity or split off an item entity. An abort puts every storage back quietly, so nothing outside saw the
change. A panic unwinding out of the closure aborts that transaction as it passes, and the one around it stays open. Open one with host.transaction. The game, not the values it hands out, holds the authority, and it keeps writing apart from ending. The closure, like any handler, is lent the Transaction: it names one open transaction by a token the game drew (unguessable in practice), and a write given one that names no open transaction changes nothing. Ending a transaction takes its TransactionSeal, which only host.transaction's guard (or whoever called the raw transaction_open) holds; the raw transaction_close refuses a seal that names no open transaction. So a handler can neither commit the transaction it was lent, nor end one around it, and a value decoded from bytes or kept past its closure does nothing. A transaction left open inside another is aborted when the one around it closes, so a caught panic or an Err still finds the rollback done. A handler keeps no journal of its own:
its state is the game's, so the game's journal covers it. Direct writes are not covered: an item entity's stack,
LivingRef::set_equipment, and the player and armor-stand slot_set paths take effect and are final at once, even
inside a transaction. Any equip/unequip event and equip sound from set_equipment also happen immediately; an
abort does not undo them. This differs from writes through journaled capability handlers.
ItemPlace::EntitySlot { entity, slot } is slot slot of such an entity's inventory (NeoForge's
Capabilities.Item.ENTITY), read and written like any other place.
Reading or writing a minecart's slots unpacks its loot table with no player, as getChestVehicleItem does.
A mod's own capability
A mod can define a capability of its own, as Mekanism defines chemicals: a resource type and a key, published from its root crate (Dependencies between trait mods) for other mods to use.
#![allow(unused)] fn main() { // The chemistry mod's root crate #[derive(Clone, PartialEq)] pub struct Chemical(pub String); pub const CHEMICALS: CapabilityKey<dyn ResourceHandler<Chemical>> = CapabilityKey::new("chem_api:chemical"); }
A mod that names it under [dependencies] builds against the same Chemical and CHEMICALS, so its providers,
r.block_entity_capability(&chem_api::CHEMICALS, &vat, …) (where vat is a declared type key or
r.block_entity_type_key("chem_pipe:vat")), answer the same capability, and
level.capability(host, &chem_api::CHEMICALS, pos, side) finds either mod's handler. move_resources moves a
chemical as it moves items, in the caller's transaction. The game has no answer of its own for a mod's key, so only
the mods' providers do, and a lookup with none registered costs one table check. A handler that keeps its amount in
its block entity's saved data (BlockEntityRef::merge_data) is covered by the game's journal, so an aborted
transaction puts it back. ItemCapabilityKey::new and EntityCapabilityKey::new define item and entity
capabilities the same way.
A key is known by its name, which must be namespaced (chem_api:chemical), and a name has one handler type. Two
mods that each declare their own Chemical under one name, rather than sharing the defining mod's, would never see
each other's handlers. So the game refuses that at startup, naming both mods and both types, and it refuses a key whose name has no
namespace. Both checks run once, when the mods register.
Entity parts and mob goals
r.entity_part("minecraft:zombie", ZombieGoals) attaches an EntityPart to one entity type, a mod's
own or vanilla's. Its method register_goals runs at every Mob construction of that type —
spawned or loaded, the moment vanilla's registerGoals fan-out ends — and the goals it adds join the
mob's two selectors beside the species' own, with the same priorities, flag locks and interruption:
#![allow(unused)] fn main() { struct ZombieGoals; #[mod_api::horizon_part] impl EntityPart for ZombieGoals { fn register_goals(&self, goals: &mut GoalRegistrar<'_>) { // goals.mob().id() is "minecraft:zombie": branch as per-species code does. goals.add_goal(7, Stare::default()); goals.add_target_goal(3, Remember::default()); } } }
goals.add_goal(priority, goal) and goals.add_target_goal(priority, goal) are GoalSelector.addGoal
on the mob's two selectors; GoalFlags claims MOVE, LOOK, JUMP and TARGET as vanilla's Goal.Flag
does, and a higher priority (a lower number) interrupts a running goal holding the same flag. A goal
object is per mob, built fresh at every construction, and its state is never saved: a goal that needs
memory across loads keeps it in attachments. A mob whose type has no part, or a game with no entity
parts at all, pays nothing — the wrapper and the tick's read view exist only where a mod goal runs.
A Goal reads and writes through its GoalContext: mob() is a fresh handle of the mob it runs on,
host() the tick's read view, random() the mob's own random (mob.getRandom(), so a mod's draws
keep the mob's stream in shape). The read view answers the ticking mob itself directly, the player the
tick sees, and every other entity from the tick's snapshot — a getEntities of the moment the mob
list was taken out — so a read the snapshot does not carry (a mob's gear or effects, its species id)
answers None, and the handle docs mark the reads that carry the snapshot rule. Writes apply to the
mob itself through the engine's own paths: move_to(pos, speed) and stop_navigation()
(PathNavigation), look_at(pos) (LookControl), jump(), and set_target(Option<LivingRef>)
(Mob.setTarget, with the write handle's rules: a fox, an enderman or a zombified piglin stores it
with its setter's side effects, and a brain-backed species refuses, since its target is a brain
memory that MobRef::set_brain_target writes from a server host). Writes to other entities
are not part of a goal; the write handles a ServerHost takes do not exist in a goal tick.
Clearing a fox's target ends defending without changing its trusted entities. An enderman's
target controls its creepy flag and attacking speed modifier; clearing it also clears its stared-at
flag. A zombified piglin schedules its first anger sound and next alert when it acquires a target
from none, not when switching between targets. Targeting a player does not count as player damage.
Brain behaviors
Native mods only: entity parts do not cross to wasm yet.
EntityPart::register_behaviors receives a BehaviorRegistrar whenever a brain-driven species builds or rebuilds its brain. Use add(activity, priority, min_duration, max_duration, requirements, behavior) to join a fresh Behavior to the species' supported activity. Registration returns a BehaviorRefusal for an unsupported activity or invalid duration. Behaviors start and stop in vanilla Brain.tick order: priority, activity bucket, then insertion order, with mod behaviors after vanilla behaviors in the same bucket. Their state is not saved. BehaviorCx provides the mob, host, random source and typed brain memory access; each species refuses memories it does not support.
The part's other method, spawn_cluster_size, changes how large a natural spawn group of the type grows
(Mob.getMaxSpawnClusterSize); controlling spawns
shows it.
Entity parts are a native mod's for now: the goal surface does not cross to wasm (see the
attach_entity_part note), so r.entity_part is absent from a wasm build.
Block-state facts
A BlockState is an API-owned value pairing a BlockKey with a block-local canonical ordinal, not a numeric engine state ID or a live object. Facts that need no world are a small
read-only service on any host, both sides: host.block_states() (import HostBlockStates). Each reads the game's own
tables through checked crossings. Fact queries and matching allocate nothing; definition copies the property data for the caller. Property operations on BlockState borrow explicit immutable block metadata; block() and ordinal() need no host:
| Method | Vanilla |
|---|---|
replaceable(state) -> Option<bool> | BlockState.canBeReplaced() |
destroy_speed(state) -> Option<f32> | BlockBehaviour.Properties.destroyTime (getDestroySpeed): stone 1.5, -1 unbreakable |
sound(state) -> Option<BlockSoundType> | BlockState.getSoundType(): volume, pitch and the break, step, place, hit and fall events |
has_block_entity(state) -> Option<bool> | BlockState.hasBlockEntity() |
map_color(state) -> Option<u8> | BlockState.getMapColor: the MapColor.id a map item colours a column by, 0 for none |
states_of(block) -> Option<BlockStateRange> | StateDefinition.getPossibleStates, as owned values in canonical local-ordinal order |
definition(block) | Property names and legal serialized values |
matches(state, block, constraints) | Partial property match with typed invalid-property and invalid-value errors |
Use StateConstraint for each partial constraint; StateMatchError identifies an invalid property or value by index. StateProperty describes a property's legal values. These types are shared by state-definition services.
None for an unknown state or block, including an invalid local ordinal, and on a host with no game behind it (a mod's test double). The ordinal order is defined by the complete canonical property constraints, not by the engine's numeric enumeration.
Fluid-state facts
Import HostFluidStates to reach host.fluid_states() on either owning side.
definition(key) returns Some(FluidDefinition::World { default, properties }) for a world fluid,
Some(FluidDefinition::TankOnly) for a registered tank-only fluid, and None without a loaded registry.
The properties and typed default come from the loaded preparation, not a second vanilla table.
These metadata reads are available in setup, reload and world callbacks after the fluid keys resolve,
not during registration or configuration.
validate(key, constraints) checks partial StateConstraint values in input order without matching
any particular state. It returns Result<(), FluidStateError>: UnknownProperty(index),
InvalidValue(index) or Duplicate(index) identifies the first offending input.
NoWorldState means the fluid is tank-only or the host has no definition; use definition to distinguish them.
type_properties(key) returns a FluidTypeProperties record, or None without a loaded registry or for
a key it doesn't hold; host.fluid_key(name) is None for an unknown name. The record is the same in every
level: description_id, light_level, fall_distance_modifier, and whether the fluid can push entities, be
swum in, drown, extinguish, hydrate and carry boats. Flowing forms answer as their source. The empty fluid and
tank-only fluids emit no light, do nothing to an entity and have a fall modifier of 1. Anything that depends on
a level, such as lava's current or source conversion, isn't in the record.
The record also carries three Fluid facts: explosion_resistance (100 for water, lava and a mod's
world fluid, 0 for the empty or a tank-only fluid), randomly_ticks (lava only) and drip_particle
(minecraft:dripping_water or minecraft:dripping_lava, none for the rest).
legacy_block(state) is FluidState.createLegacyBlock: the water, lava or mod-fluid block that holds a
fluid state (a source is level 0, a flowing amount is 8 - amount, falling adds 8) and air for the empty
fluid. It is None for a state no fluid can be in: a tank-only fluid, an amount above 8, a zero amount
for anything but the empty fluid, or a source that isn't full.
What depends on a position is on LevelRef, not in this service: fluid_tick_delay(host, fluid, pos)
(water 5, lava 30 and 10 in the Nether, a mod fluid's as its fluid_rules parts change it there),
fluid_height, fluid_height_for_camera and fluid_can_be_replaced_with(host, pos, fluid, direction).
fluid_tick(server, pos) runs a fluid's flow step now; schedule_fluid_tick queues one.
FluidState::matches(constraints) is a pure local check of carried vanilla fields. Unknown properties
or noncanonical serialized values return false. A falling source remains a source; a flowing state
of level 8 remains flowing. Empty constraints match any state. Validate separately for indexed errors.
Neither matching nor validation allocates; explicitly copying a definition may allocate.
Unresolved saved tank identities remain FluidIdentity::Unresolved(handle), not world states or tank-only keys.
Fake players
host.fake_player(level, uuid, name) makes, or finds again, a player with no connection in the level in place, as
NeoForge's FakePlayerFactory does: a machine that acts as a player (a deployer, a block breaker). It is no
online player: the tab list, player counts, sleeping, natural spawning, chunk tickets, statistics and saves leave
it out. player.is_fake(host) tells one apart; player.remove_fake(host) removes it.
player.perform_action(host, action) makes it act as a player does, through the game's own path for the action, as
that player's packet would run it:
PlayerAction::UseOn { hand, pos, hit }is a click on a block (ServerPlayerGameMode.useItemOn): reach, the hit's place on the block, the build height, spawn protection and the border, then the block's use and the item'suseOn, which is how a block item is placed;PlayerAction::UseItem { hand }is a use in the air (ServerPlayerGameMode.useItem), by the item's own ray from the player's eyes and look: a bucket clicked on a block passes, as vanilla's does, and empties when used.PlayerAction::InteractOn { hand, target, location }interacts with that entity (Player.interactOn), after the border and interaction-range checks, with the hit relative to its origin. A passed click does not fall back to using the item in the air. The fake keeps its own supplied look, rather than aiming a new ray;PlayerAction::Attack { target }is a hit with the main hand (handleInteract's ATTACK, thenPlayer.attack): the border and the attack range, the native mods'attack_entity, then the blow on a player, a mob or another entity, or a fireball, wind charge or shulker bullet turned back. The blow is scaled by the attack strength, as a player's is, so a fake that hits every tick does a fraction of a full blow. A mob doesn't take the fake as the player to answer;PlayerAction::BreakBlock { pos }breaks the block at once (ServerPlayerGameMode.destroyBlock): reach, the build height, spawn protection and the border, then the game mode's and the held tool's rules (adventure mode can't, a sword in creative can't, and outside creative a block no digging breaks, destroy time -1 such as bedrock, stays), the native mods'break_block, the tool's wear and the drops. There is no digging time: a machine that digs over time counts its own progress and breaks the block when it is done.
Its items are its own inventory: set the stack in its hand first (through ENTITY_ITEMS or an ItemPlace), and
read what is left after. The answer says what happened:
| Answer | Meaning |
|---|---|
Ok(ActionOutcome::Consumed) | Vanilla's InteractionResult consumed (a success or a consume), the blow landed, or the block broke |
Ok(ActionOutcome::Passed) | Nothing used the click, a mod or the game stopped the blow, or the block stayed |
Ok(ActionOutcome::EffectBlocked(effect)) | The click ran, but what it would start was stopped: a menu or screen, sleeping, riding, or going to another dimension (FakeEffect). The click's own side effects have still happened then: the swing seen by others, the hooks that run before the use, vanilla's Success |
Err(ActionRefused::…) | Nothing ran: NotFake (a real player's handle), Stale, Busy (another player's turn, an open transaction, or a hook inside another perform_action), OutOfReach, Protected, InvalidSetup (non-finite scoped viewpoint or entity hit), or InvalidTarget (an interaction or attack on the fake itself or an entity not in its level) |
For a machine using an isolated stack, player.perform_action_scoped(host, action, setup) composes that same
persistent player's action with temporary state. Build PlayerActionSetup::new(stack, eye, yaw, pitch, secondary_use): the fake acts in survival with a fresh standing body (eye height 1.62), that stack in its main
hand, and no other inventory or equipment. Its supplied rotation is used for item rays. Secondary use holds
semantic sneak without changing that standing eye height. No second player is created.
The ScopedActionResult contains outcome (the same ActionOutcome), held (the nonempty main-hand remainder)
and other (other nonempty inventory stacks, then offhand, carried stack and armor). These stacks are returned
to the caller, not kept by the fake. The fake's persistent body, inventory, game mode, look, secondary-use state
and action timers are restored even on refusal; world changes and the usual interaction/Parts hooks remain.
The ordinary fake safety rules still apply: no screens, sleep, rides, dimension travel, online participation,
statistics, advancement credit or network connection. Neither action method can run inside another player turn
or an open transaction; the action's own ordinary write paths still run transactionally.
On either side, player.entity().is_shift_key_down(host) reads semantic secondary use rather than a physical
key mapping or a crouching pose. On the client obtain the reachable local handle with host.local_player()
(or host.entity(id)); it reads live secondary use even before the next movement tick updates the body's
shift flag. On the server the existing entity flag is read, including a scoped fake's setup. Stale handles and
clients without a joined local player return None.
What a machine does beyond a player's action stays in the mod: its own mining progress and catching world drops. Scoped actions retain player reach, protection and border checks; eye placement is supplied by the mod.
Testing a trait mod
Enable the testing feature on the mod's horizon-mod-api test dependency. Then
testing::with_mods takes an owned Mods list and runs a closure with it installed on the current thread.
It restores the previous list even if the closure panics. The test allocation is retained because dispatch exposes
a static slice. Call dispatch_<hook>(&mut host, args…) inside the closure to build the event as the game does.
testing::FakeHost is a server and client host built from one small fake per service, so a test runs without
the game: registries (item reports, items with slots of their own, the loaded mods), players (who is online,
their inventories by slot, what was given to them and shown on their action bar), level (blocks, block entities
with item slots, fluid tanks and energy storage as the game answers them, and entities), commands and
messages (what ran and what was said), and journal, which keeps the players and the level on each transaction
and puts them back when it aborts. Set the world up through those fields, run the code under test with
&mut host, then read the fakes back:
#![allow(unused)] fn main() { use mod_api::testing::{FakeHost, with_mods}; use mod_api::{self, ClientHost, ClientTickPreEvent, Hooks, ModEntry, dispatch_client_tick_pre}; use std::sync::{Arc, atomic::{AtomicUsize, Ordering}}; #[derive(Default)] struct Counter(Arc<AtomicUsize>); #[mod_api::horizon_mod] impl Hooks for Counter { fn client_tick_pre(&self, _: &mut dyn ClientHost, _: &mut ClientTickPreEvent) { self.0.fetch_add(1, Ordering::Relaxed); } } let count = Arc::new(AtomicUsize::new(0)); let mods = [ModEntry::new("counter", Box::new(Counter(count.clone())))]; with_mods(mods.into(), || dispatch_client_tick_pre(&mut FakeHost::new())); assert_eq!(count.load(Ordering::Relaxed), 1); }
A test of item moves puts two chests in the level and reads back what went where; the transaction's journal is the fake's:
#![allow(unused)] fn main() { use mod_api::testing::FakeHost; use mod_api::{HostTransaction, ITEMS, ItemResource, ItemStack, ServerHost, move_resources}; let mut host = FakeHost::new(); let level = host.level.level(); host.level.block_entity([0, 64, 0]).with_items(1); host.level.block_entity([1, 64, 0]).with_items(1); let stone = ItemStack { item: "minecraft:stone".into(), count: 5, damage: 0, extra: None }; host.level.block_entity([0, 64, 0]).items.as_mut().unwrap().slots[0] = Some(stone); let server: &mut dyn ServerHost = &mut host; let from = level.capability(server, &ITEMS, [0, 64, 0], None).unwrap(); let into = level.capability(server, &ITEMS, [1, 64, 0], None).unwrap(); let moved = server.transaction(|host, tx| { move_resources(host, &from, &into, |_: &ItemResource| true, 3, tx) }); assert_eq!(moved.ok(), Some(3)); let into = host.level.block_entity_at([1, 64, 0]).unwrap().items.as_ref().unwrap(); assert_eq!(into.count("minecraft:stone"), 3); }
A Host method no fake answers panics naming it, as every Host method defaults to, so an unexpected call
fails loudly. A test can still write its own double for what the fakes don't model, implementing only what its
hook calls; testing::FakeJournal gives such a host the same transaction journal over its own state.
If a Host method returns a game value a test can't build, a double can return None instead, as with
Players::acting_player() -> Option<PlayerRef>. If the dispatch function itself takes an unconstructible value,
such as dispatch_perform_command's &ServerPlayer, test the logic beneath the hook instead. The level
hosts are small enough to implement whole. NeoForge's GameTest is the in-game counterpart.
Adding a hook
A new hook needs its declaration in the area file of crates/horizon-mod-vocab/src/hooks/ (/// lines and
#[hook(side = "server", contract = "cancel", out = "cancel")] on fn name();; the argument list is in that folder's mod.rs), its native_* function with the placeholder body
let _ = (every, parameter); (all its parameters, in order), and a run of cargo run -p xtask -- mod-api.
Put that function in crates/horizon-client/src/game/native_hooks/<area>.rs, matching the declaration's
area filename. Alternatively, a named hook may live in any logical module under game/, including
area folders, mod.rs and #[path] modules. Its target must name that module and
Game<'_>::native_<name>. Exactly one matching Game method must exist. Outside native_hooks/,
the method must call dispatch_<name> itself: placeholders are not filled there. Remove macros,
cfg and cfg_attr from that method so injections can patch it. A refusal names the hook and
what to change. These checks run at build time and add no dispatch-time work.
The build discovers native hook modules automatically; do not add a module list entry. Shared
support lives in shared.rs; fire.rs includes generated dispatch entry points, not hook implementations.
That command only replaces the placeholder in its owning area file with the dispatch_<hook> call; commit
that source change and the declaration. Fills are idempotent; rerun the command after a failed write. The horizon-mod-api-gen build dependency generates the hook traits,
Host methods and dispatch code into Cargo's OUT_DIR when the game and renderer build, including release
builds. These generated Rust files are not kept in the source tree or committed, and no manual regeneration
is needed after a merge. Every hook needs a mod_api entry: the build fails naming any hook without one. A hook that stands in for a
method a vanilla block, block entity, item or menu overrides also gets a scope and a key
({"contract": "collect", "out": "output", "scope": "block", "key": "state"}): the key is a
BlockState (routed by its prepared block key for block parts), an &ItemStack, a ModBlockEntity or an id &str, and
the generator also puts the method on that scope's part trait (Parts). A body that is neither the
placeholder nor already calls the dispatch is an error, and the generator never changes it. Hooks that are
free functions outside Game are wired by hand: those crates can't see the mod list, so each calls a callback
the executable installs once, such as NativeLoadHooks in horizon-world and NativeClientHooks in the
client library (menu_slot_rules, living_breathe, ingredient). A build reports a placeholder still to
fill instead of changing hand-written source; run cargo run -p xtask -- mod-api to fill it. Mixin injections
go at the head of the body, before the dispatch, so they still run first.
Server attachments
A mod can declare an AttachmentType<T> once and call its get, set, remove,
has and get_existing methods with the hook's host and a Holder. T is
Clone + Send + Sync + 'static; get returns a clone and inserts the default on
first access. has and get_existing read without inserting (NeoForge's hasData
and getExistingData): has says whether a value is stored, saved or set, even
before it decodes, and get_existing returns None instead of the default. Neither
adds anything to the holder's saved data. set runs the optional codec before
changing the value, returning an AttachmentError if it cannot encode.
Keys are the pair (namespace, name); a key already decoded as another Rust type is
an error. On a server, a missing holder is an error, not a new phantom holder; a client
keeps a copy for any holder it is asked about (see the sync paragraph below).
AttachmentError distinguishes MissingHolder, TypeMismatch, Decode, Encode,
InvalidNamespace (a malformed saved compound), InvalidCopyPolicy (death-copy
without a codec) and InvalidSyncPolicy (sync without a codec).
Every host is an AttachmentStore (and host.attachments() gives it): the typed methods
stand on its few erased ones, so they work the same in a native mod and a wasm mod. A
value keeps its own Rust type, shared: reading one clones it out, and copying a holder's
data copies no value.
AttachmentHolder keeps an existing typed handle when a system needs to store a holder
identity: Entity, Player, BlockEntity, Chunk or Level. It is Copy, does not keep
anything loaded, and follows each handle's ordinary stale-world and unload rules. Unlike
the storage-relative Holder, it carries the handle's world scope.
Import AttachmentQueries to use host.query(&TYPE) on either host side. The
returned batch is a snapshot of membership and stored value bindings in the
host's current dimension. Its iter() borrows &T without cloning values and
lazily decodes saved entries; malformed or incompatible values produce row errors,
not defaults. Later sets, removals and unloads appear only in fresh queries. This
is a residency view: an entity queued for removal but not yet swept can still
appear. Captured handles do not keep holders loaded, and stale writes fail.
Interior mutability inside T is not deep-frozen by a snapshot.
On a server, import AttachmentQueriesMut for host.query_mut(&TYPE) (T: Clone).
Each borrowed row has holder(), value(), and explicit set(host, replacement) /
remove(host) methods. These validate residency against the host's current
dimension and then use the ordinary attachment journal. There is no &mut T,
implicit transaction, or commit on drop: wrap a system in host.transaction for
all-or-nothing writes. Unload or replacement produces AttachmentRowError::StaleHolder.
Rows retain captured values even after successful write-through updates.
Traversal follows dense index order: insertion appends, and removal fills its slot
with the last row. The same ordered mutation history is deterministic, but the
order is neither sorted nor stable across structural changes. Retaining a batch
can make the next live mutation copy row metadata and shared pointers once;
retaining many batches keeps their old bindings alive. Native acquisition clones
only the bucket Arc. Erased values from an incompatible
type domain report TypeMismatch rather than falling back to saved decoding.
#![allow(unused)] fn main() { use mod_api::{AttachmentCodec, AttachmentError, AttachmentType, Compound, Holder, Tag}; static COUNTER: AttachmentType<i32> = AttachmentType { namespace: "example", name: "counter", default: || 0, codec: Some(AttachmentCodec { encode: |n| Ok([("count".into(), Tag::Int(*n))].into()), decode: |data: &Compound| match data.get(&"count".into()) { Some(Tag::Int(n)) => Ok(*n), _ => Err(AttachmentError::Decode), }, }), copy_on_death: false, sync: false, }; // Inside a server hook, or mixin code, with `host: &mut dyn ServerHost`: if !COUNTER.has(host, Holder::Chunk([0, 0]))? { /* chunk carries no counter yet */ } let old = COUNTER.get(host, Holder::Chunk([0, 0]))?; COUNTER.set(host, Holder::Chunk([0, 0]), old + 1)?; Ok::<(), AttachmentError>(()) }
For mixin code, see The typed API from mixin code.
Use Holder::Level, Holder::Chunk([x, z]), Holder::BlockEntity([x, y, z]),
Holder::Entity(id), or Holder::Player(uuid). Entity IDs cover every entity in
the level apart from players: mobs, items, experience orbs, projectiles, tridents,
falling blocks, primed TNT, fireworks, boats, minecarts, armor stands, cushions, item
frames, paintings, End crystals, the ender dragon, ominous item spawners and the
server's OtherEntity records. An entity keeps its UUID through saves, so its saved
attachments load back with it; an orb saved with a count above one loads as several
orbs, and only the first keeps the attachments. Player UUIDs cover online guests and
the acting player. Mutable level
hosts can access their level, loaded chunks and block entities; read-only level hosts
cannot access attachments. Unserialized values disappear on unload or restart. A saved
value is stored under horizon:attachments, then the mod namespace and attachment name,
as {value: <compound>, copy_on_death: 1b, sync: 1b} (a flag is omitted when false).
Unknown mods' saved entries survive load and save without being decoded. Level
attachments use the dimension's data/minecraft/horizon_attachments.dat SavedData
file, not level.dat.
A type with sync: true sends its value to Horizon clients when get creates the
default, on set and remove, and on demand with AttachmentType::sync_data(host, holder); changes made in one tick are sent once, at its end. The recipients are the
players who track the holder: guests who were sent a chunk for a chunk or block entity,
who track an entity for an entity, the player and those tracking them for a player, and
everyone in the level for the level. A client also gets all of a holder's synced values
when it starts seeing it: a chunk sent (with its block entities), a level joined or
re-entered, an entity tracked, and its own player. With no native
mod loaded nothing is sent. The wire form is the codec's compound, so sync requires
a codec.
Only Horizon clients are sent values. A message with a namespace or name over 256 characters, a value that cannot be written or a payload past 1 MiB is not sent.
On a client, Host::attachments returns a copy of what the server synced, which get
decodes; set and remove change only that copy, which is dropped when the chunk,
entity or player goes. A client keeps no copy for the level hosts (LevelRandomHost):
they stay server-only there, though their changes on a server are synced.
copy_on_death requires a codec. Player respawn copies opted-in entries via their
encoded representation, including raw entries of an absent mod; other entries are
removed. Living-entity conversion is not supported. This follows the default supplier,
optional server-side persistence, player-respawn option and sync with a StreamCodec
(with syncData) of NeoForge 26.3's public AttachmentType.Builder contract, and
IAttachmentHolder's hasData/getExistingData, but not its holder-aware supplier,
custom copy handler, conditional serialization, the sendToPlayer predicate,
AttachmentSyncHandler partial updates or a wire codec apart from the disk codec.
The API owns these public names. Their engine-independent storage and query index
live in horizon-mod-runtime, which depends only on the shared vocabulary in
horizon-core; world and game storage use the same implementation.
Host helpers and remaining gaps
Host exposes the documented Game helpers declared by area in
crates/horizon-mod-vocab/src/host/<area>.rs, one trait per area; Host, ServerHost and ClientHost are the
unions of those traits, so a mod calls through them and never names an area trait. Each helper keeps its
receiver and documentation; a helper without self (config_files) takes
&self in Host. Level services stay on the level hosts above.
For the per-method comparison with NeoForge's block, item, entity, block-entity and fluid extension interfaces,
including which gaps are tracked, see NeoForge extension coverage.
For item registry facts, host.item_crafting_remainder(&stack) returns a count-one
plain remainder stack, or None when there is none or the item is unknown. Horizon
currently stores only the remainder item id, not a template's count or components.
host.block_item(stone) returns its item registry name; an unavailable block mapping
returns None, while a known block with no item returns Some("minecraft:air").
Both reads are available on either side.
For engine contributors, every area method declares its load access once with #[access(...)].
Metadata admits registry facts during setup and reload; WorldOnly, ClientSetup and
PublishedResources retain their phase-specific contracts. The area's host_area attribute
builds the test-double defaults, and the API generator emits every game forwarder at build time.
Implement only the inherent Game function with the same name, not a hand-written forwarder.
Static implementations use #[static_call]; service accessors use #[service].
A helper's receiver also exposes it on that handle. Receivers come from public
*Ref structs in mod_api.rs: BlockEntityRef gives block_entity, for example.
Adding a handle requires no separate generator registration.
The following documented Game helpers are not in the trait yet:
spawn_mob,mob,mobs,block_in_tag,item_in_tag,server_block_in_tag,server_item_in_tag,server_block,set_display,sidebar,experience_orbs,item_entities,player,panel_recipes,recipes,component,set_component,remove_component,spawn_falling_block,explode,container_slotsuses gameplay-private types.with_mod_block_entityis generic, so its signature cannot be called throughdyn Host.
Experience-orb values use vanilla's signed i32 representation in
ExperienceOrb, ServerHost::set_experience_orb, and the pickup and merge hooks.
Reading or changing a value preserves zero and negative saved values rather than
clamping them through an unsigned representation. A negative value does not grant
positive experience when picked up.
Advancement triggers instead live on PlayerRef::trigger_criterion, with a mod-owned
CriterionTest and the game's scoped CriterionContext resource. The context keeps real
world-aware item predicates; it is not a conditions-only snapshot. See
advancement triggers for the contract and
the wasm runtime's current same-group callback limitation.
A mod that consumes a block use can run vanilla advancement predicates and rewards
through PlayerRef::trigger_block_use(host, pos) or
PlayerRef::trigger_item_used_on_block(host, pos, original, after). These server
operations use the player's own level and never read inventory. Default use fires
default_block_use, then any_block_use, both without a tool: the default helper
is specifically tool-free, not general default block use while holding an item.
Item use fires item_used_on_block against original (copied after a before-block
PASS and before block/item effects), then any_block_use against after (that
original hand stack mutated in place, excluding a replacement swapped into the
hand), in that order. Consuming right-click/before-block cancellation already
fires automatic any_block_use only; do not call either pair helper for it,
which would duplicate any and add an inner trigger. This follows
ServerPlayerGameMode.useItemOn and ServerGamePacketListenerImpl.handleUseItemOn;
the predicate evaluators are DefaultBlockInteractionTrigger,
ItemUsedOnLocationTrigger, and AnyBlockInteractionTrigger.
Ok(()) means the player was reached, even if no criterion matched. Failures are
NotServer for a remote client, PlayerUnavailable for a stale, offline or
displaced handle, UnloadedChunk for a position outside loaded chunks, and
Stack(StackWriteError) when the item boundary rejects either stack before any
trigger fires. Native and wasm use the same generated operations. The
minecraft: guard on trigger_criterion remains: this grants no general access
to fire arbitrary vanilla triggers.
The following catalog hooks are not in the trait:
player_command,item_decorations,client_resources_reloaded,camera_shake,screen_key_pressed_pre,screen_char_typed_pre,screen_mouse_scrolled_pre,use_duration,use_animation,can_eat,living_entity_use_item_start,living_entity_use_item_finish,render_hand,shader_paramsandblock_highlight.biome_colors,camera_distance,compute_fog_color,compute_fov,data_pack_files,mob_submit,mouse_button_pre,mouse_button_post,player_respawnedandtags_updated.
The client render_tooltip_texture hook (NeoForge's RenderTooltipEvent.Texture) picks a tooltip box's look. Setting texture to a tooltip style <namespace>:<name> draws the GUI sprites <namespace>:tooltip/<name>_background and _frame from the resource packs, nine-sliced by their gui.scaling metadata or stretched without it, as vanilla draws an item's tooltip_style; the atlas a resource reload restitches is the one drawn. Left None, the box keeps Horizon's solid fills, whose background and top and bottom border gradient colours it changes as mutable ARGB u32 values; it does not change text colours. It covers solid-fill item tooltips (including creative, recipe book, chat, trading and bundle-selected item names), screen text boxes, and native panel tooltips. The stack is None for text-only boxes, including recipe-book item lines where the stack is discarded before drawing. Sprite-based screen tooltips (including options and title screens) use a separate renderer and are not covered.
Tooltip component geometry
render_tooltip_gather_components preserves the mixed text/image list, including
an image inserted immediately after the name. Wrapping splits text in place.
The text pass visits the entire list before the image pass starts; both passes
advance through the same component heights, with 2 extra pixels after the first
component of an item tooltip. Each image callback receives the box's left edge
and its own running top, but the whole content box size, not TooltipImage.size.
That field is the component measurement used to reserve space. The box width is
the widest component; its height is the sum of heights, minus 2 normally, with no
subtraction for item tooltips containing multiple components. Text components
measure 10 pixels high. Factory registration and selectable fonts are separate
capabilities, not provided by this geometry contract.
Camera and collision reads
ClientHost::is_paused() reads the applied client pause state, not whether a pause
screen is open. A multiplayer game continues running while its pause screen is open.
The pause-change pre hook sees the old state; the post hook sees the new state.
ClientHost::playing() is true while the player is in the world with nothing over it: no screen, title, sign
editor or book, so keys and zoom apply. ClientHost::mapping(name) reads a vanilla key mapping (key.forward,
key.left, key.back, key.right, key.jump, key.sneak, key.sprint, key.attack, key.use) as a
KeyMapping: whether it is held (KeyMapping.isDown) and the label of the key it is bound to, as Controls
shows it. It is None for any other name. Both read without &mut, so the HUD and screen hooks can ask them.
ClientHost::clipboard_text() reads the system clipboard (KeyboardHandler.getClipboard, empty text when it
holds none) and ClientHost::set_clipboard_text(text) writes it (setClipboard; empty text copies nothing). Both
work only while the game handles a key, mouse or typed-text event, from the window or from the control socket
(horizon-ctl key, mouse, type and its clicks): a key, mouse or screen hook that fires inside one, such as
Ctrl+V into a mod's own field. At any other moment (a tick, a frame, a packet) the read is None and the write
is false with nothing copied, so no mod reaches the clipboard without the player doing something. Both are
None and false on a server, and take &self, so a screen hook can call them. Vanilla's KeyboardHandler
has no such rule: Horizon adds it.
The camera() helper returns the most recently extracted world frame's camera, or none before one exists. Its position, interpolated eye height, partial tick, angles, final FOV, near/far planes and dimension accompany column-major view and projection matrices. The view is camera-relative and includes bob and spin; projection uses reversed Z. Earlier callbacks in a frame can see the previous frame. block_collision(pos) returns block-local [min_x, min_y, min_z, max_x, max_y, max_z] boxes with an empty collision context; unloaded blocks and collision errors give no boxes.
ClientHost::set_camera_type(type) is a queued client-only effect and takes &self, so
shared callbacks such as camera_pose can request a perspective. camera_type() reports
the latest request immediately; several requests coalesce to the last one. Requests before
or during camera_pose apply before that frame's final camera, FOV and spectator post effects.
The pose hook runs only once: its explicit position or angle overrides survive a refreshed
vanilla camera. Requests from later render callbacks apply at the next frame's extraction.
Setting a type never emits camera_type_cycle; F5 alone cycles, and its final event choice
wins over requests made inside the cycle callback.
ClientHost::crosshair_target() returns the ordinary interaction-range target as
CrosshairTarget::Block { pos, state, hit } or
CrosshairTarget::Entity { entity, point }. The block hit contains its world-space
location, face and inside flag; the entity point is also in world coordinates.
It uses the game's current targeting ray and block/entity reach, respects blocks
occluding entities, and returns None on a miss or without a client world.
It can be read with a screen open. entity is an EntityRef carrying the current
client level's epoch and network entity ID, including dragon parts. It becomes stale
when the client level is replaced or the client disconnects; a same-level respawn
keeps its epoch. This supplies identity, not client entity reads: the server-only
entity data methods still reject client handles. Unlike view_hit and raycast, this does not find blocks
in distant terrain or return a block behind a targeted entity.
Combat targeting
ClientHost::targeting() and set_targeting(ClientTargeting) control liquid picking,
entity occlusion and an optional crosshair entity override. Defaults preserve ordinary
vanilla targeting. Set pick_liquids to include source and flowing fluid shapes, or
hit_through_walls to ignore block occlusion for entity picks and attacks. An entity
override must name a live entity in this client level; stale or self handles make the
setter return false without changing the controls. The setter requires a logged-in
client world. An override is rechecked for reach, held-item range and occlusion when
used and disappears from reads after its level is replaced.
Reach remains attribute-backed: use the server player's living handle and
set_attribute_base(host, "minecraft:block_interaction_range", value) or
"minecraft:entity_interaction_range". The client's cached reach follows the server's
attribute updates. Client targeting controls do not grant extra server reach.
client_attack_target receives a mutable targets list before an ordinary melee click
(spear jabs use their existing path). It starts with the crosshair entity. Replace it
for redirection, append for multiple hits, or clear it to suppress an entity attack.
Flow::Cancel cancels the batch. There is no batch cap; duplicates are sent once
in first-occurrence order. Each target is checked again against live handles,
attackability, reach, held-item range and the occlusion control. Clearing an entity hit
does not mine the block behind it; an empty list on a block or miss keeps the ordinary
path. Each accepted hit uses normal attack packets and prediction. Server validation
and cooldowns still apply. Native and Wasm mods use the same typed API.
Item model values
Conditional, numeric and categorical item model properties and item tint sources
receive an API-owned ItemStack snapshot. The engine's resolver keeps its own
StackView; that engine type is not part of mod_api. This applies to nested
bundle contents as well as the outer item being drawn.
| Hook | Snapshot | Runtime selection | Answer |
|---|---|---|---|
item_model_property | event.stack: &ItemStack | event.bundle_selected: i32 | event.value: bool |
item_model_range_property | event.stack: &ItemStack | event.bundle_selected: i32 | event.value: f32 |
item_model_select_property | event.stack: &ItemStack | event.bundle_selected: i32 | event.value: &mut Option<String> |
item_tint_source | event.stack: &ItemStack | event.bundle_selected: i32 | event.color: u32 |
bundle_selected is the runtime bundle selection index: -1 means none. It is
not a saved component and is deliberately separate from the stack's component
patch. The patch contains explicit overrides and removals, not merged defaults.
The host is &dyn ClientHost for each callback. Only answer property names or
sources the mod owns; unanswered callbacks retain their existing defaults.
ItemTintSourceEvent.config is a borrowed Tag, converted once from the tint's JSON definition at load. Read fields with Tag::get and match tag variants. Invalid payloads refuse model loading; resolving a tint does not convert or clone its payload.
| Former resolver-view access | Owned API access |
|---|---|
event.stack.item | event.stack.item() returns ItemKey; retain keys for comparisons |
event.stack.count | event.stack.count() |
event.stack.patch | event.stack.components() returns an optional owned NBT patch; use event.stack.get(&descriptor) for a typed component |
event.stack.bundle_selected | event.bundle_selected |
A native or wasm listener sees the same owned values. Snapshot construction occurs only after the subscription check, so a model resolution with no listeners does not allocate or encode a snapshot. An empty or unresolved resolver view does not dispatch an invalid stack; its property or tint retains the unanswered default.
Owned item values
ItemStack is the mod API's own nonempty value, not an engine inventory slot or a borrowed
rendering view. It contains an ItemKey, a nonzero count, an optional numeric damage override
and a lazily allocated component patch. Plain stacks allocate no component storage. An empty
slot or a consumed stack is None; it is never a zero-count ItemStack.
Every loaded item, vanilla or modded, has an ItemKey. Resolve identifiers through
Host::item_key once when loading data and retain the key. Forward references requested during
registration keep their slots when the load completes; unresolved references refuse the load.
Keys are process-local identities, not persistent names. Tags likewise have retained TagKey
identities, while their item and fluid membership is read through Host::tags and changes on
reload. Unknown tags are empty; unavailable authoritative mappings return None.
Stored values and effective components
Count and component edits do not consult the game. new refuses zero; set_count and grow
refuse invalid or overflowing counts without changing the original value. Consuming split
and shrink return optional portions so consuming the last item needs no empty sentinel.
The damage representation has three states:
| Stored state | Effective meaning |
|---|---|
| No numeric override and no removal | Inherit the item's prototype, including its default damage of zero when present. A new plain sword needs no Components allocation. |
Some(0) | An explicitly present zero-damage component, even on an item without a damage default. set_damage(0) creates this state without allocating a patch. |
No numeric override and !minecraft:damage in the patch | The component is explicitly removed, including an inherited default. clear_damage creates this state. |
damage() returns the stored number or zero. damage_component() distinguishes an explicit
zero from no numeric override. set_damage(u32) is infallible; the game validates its effective
maximum when accepting a write. Adding only a maximum-damage override to an item does not
invent a damage component: explicitly adding Some(0) is what makes that component present.
All numeric damage lives in the optional field. Raw damage entries normalize out of the
component patch; malformed numbers, conflicting numeric values, or a simultaneous value and
removal refuse the entire replacement. Generic component setters and raw set_components
therefore return Result. A refusal leaves the stack unchanged. Typed damage setters do not
need an error path.
ItemResource::with_component is also fallible for raw component values. Its default-value
elision and without_component preserve the normal prototype-plus-patch semantics. The latter
records real NBT removal entries for default components; it is not an unknown-value fallback.
Game boundaries
A consume or death-protection override whose opaque native effect payload cannot be decoded
refuses the whole stack write with StackWriteError::InvalidEffectPayload, before any
resident state changes. The engine's infallible preserved-component setter remains infallible:
invalid replacements retain its prior raw component and decoded cache rather than erasing them.
The items host area owns metadata reads such as max_stack_size(ItemKey) and
max_damage(ItemKey). A stack does not take a host just to read a maximum, and an unavailable
maximum never turns into a fabricated limit of 64.
The game validates effective count and durability limits, including component overrides, when
accepting a resident write. An effective max_stack_size must be in 1..=99, and the
presence of max_damage forbids a stack limit above one, even when the current count is one.
Removing max_stack_size uses the absent limit of one. Invalid values produce typed errors,
not clamped values. A transfer request is different: ItemResource plus a separate amount can request or
return more than one resident stack's capacity. The resident writes it produces still have to
pass validation.
Game inventory, menus, trades and models keep their engine-owned representations. Native
hooks and host methods exchange owned API snapshots. The game converts only after a listener
guard, and stages every mutable result before committing any captured output. Invalid hook
edits retain those captured engine values. Consuming mutable events such as bonemeal and
item_after_vertical_collision lend Option<ItemStack> so a listener can consume the value by
setting None.
The item_stacked_on_other bridge commits its clicked slot, cursor and cancellation flags as
one unit, including the menu's slot-operation queues and map-id reservations. The block-entity
tick, menu-button and menu-quick-move bridges validate their captured snapshot outputs together
before applying them. These are output guarantees, not whole-world transactions: independent
ServerHost storage writes, commands, messages and other effects are not rolled back on a
refused output. Carrier-form callbacks such as bonemeal and item_after_vertical_collision
also retain their captured stack outputs on refusal but do not journal independent host writes.
The storage-journal follow-up covers that gap.
Restricted in-write LevelHost callbacks keep their existing write and random-draw order.
Item-model callbacks receive the owned stack and a separate bundle_selected number (-1
means none). Runtime selection is not a persistent component. Drawing receives the API-owned
ItemDisplayContext, not the renderer's enum.
The NBT escape
ItemStack::from_nbt(host, compound) and to_nbt(host) are the NBT escape. They live on the
plain API value, not as a second engine implementation in the items host area. They use only
typed host key lookup and canonical id metadata, so native and WebAssembly mods execute the
same parsing and serialization code. Persistent NBT contains identifiers, never process-local
key slots. Unknown ids, malformed values and zero counts fail decoding; unavailable id
metadata fails encoding.
The escape preserves explicit zero damage, default-component removals and opaque component NBT. It does not apply resident maxima or materialize prototype defaults. Those remain the responsibility of the metadata service and game write boundary respectively.
Delayed tasks
A server mod asks for a call back after some server ticks with schedule_task, the stand-in for
vanilla's MinecraftServer.tell(new TickTask(getTickCount() + delay, runnable)). A closure cannot
cross into a wasm mod or outlive the host a hook borrows, so the callback is a hook: the mod passes
a u64 token and Hooks::scheduled_task hands it back.
const REMINDER: u64 = 1;
impl Hooks for MyMod {
fn player_logged_in(&self, host: &mut dyn ServerHost, player: u128, name: &str) {
// 20 server ticks from now: one second at the normal rate.
if let Ok(id) = host.schedule_task(20, REMINDER) {
// Keep `id` if the reminder may need to be withdrawn: host.cancel_task(id).
}
}
fn scheduled_task(&self, host: &mut dyn ServerHost, id: TaskId, owner: &str, token: u64) {
if owner != "mymod" || token != REMINDER {
return; // every mod is asked about every task
}
// The task has fired and its id is spent.
}
}
| Call | Meaning |
|---|---|
host.schedule_task(delay_ticks, token) -> Result<TaskId, ScheduleError> | A task due delay_ticks server ticks from now. 0 and 1 both mean the next tick. |
host.cancel_task(id) -> bool | Withdraws a task. True only if it is yours and still waiting. |
Hooks::scheduled_task(host, id, owner, token) | The callback, on the server thread. |
Timing. Delays count ticks the server runs: a paused integrated server does not advance them. A
task fires at the start of its due tick, right after server_tick's start phase, in the order the
tasks were scheduled. A task never fires in the tick that scheduled it, whichever hook scheduled it,
the tick's start hook and scheduled_task itself included: it fires on a later tick.
Ownership. The task belongs to the mod whose hook called schedule_task; the game takes that from
the running hook, never from an argument. Outside a mod's hook the call returns
ScheduleError::NotInMod. cancel_task answers false for another mod's id, an unknown one, and one
that has fired or been cancelled. Every mod hears scheduled_task for every due task, so a mod
compares owner with its own id and ignores the rest. Ids are never reused in a world.
Limits. A mod may have MAX_PENDING_TASKS (4096) tasks waiting; the next schedule_task returns
ScheduleError::TooMany until one fires or is cancelled. A task that panics does not drop the others
due on the same tick: they still run, and the panic goes on afterwards as any hook's does.
Lifetime. The queue belongs to the running world. It is dropped when the world opens or closes (including a failed open or close), and is never saved. A mod that must outlive a restart writes its deadline to its own storage and schedules again when the world opens. A mod's tasks are not cancelled when the mod unloads; the world's close is the end of them.
Extinguishing fire
EntityRef::extinguish_fire(host) exposes vanilla Entity.extinguishFire on
players, guests, mobs, armor stands and dropped items. If the entity is burning,
it first emits minecraft:entity.generic.extinguish_fire at its feet with volume
0.7 and vanilla's pitch from two ordered random float draws. It then clears
positive remaining fire ticks, preserving a negative fire-immunity countdown.
Fire-immune mob kinds and dropped stacks resistant to in-fire damage do not
count as burning, even when their timer is positive.
The operation reuses the engine's mob sound outbox and entity random, and the
player/armor-stand cosmetic random and captured sound route. Dropped items use
their own random. Sounds use the entity's sound source, including Players for
a guest rather than a generic sound category. It does not draw pitch randomness
for a non-burning entity. It returns false for stale or unsupported handles.
EntityRef::clear_fire(host) is the distinct vanilla Entity.clearFire operation:
it is silent, draws no random, and preserves the same negative countdown. Choose
it only when the original behavior explicitly calls clearFire, rather than
using it as a substitute for extinguishing.
Where the mod API is going
The mod API is built toward one shape, and every change to it is judged against that shape. This page explains the shape and why it is the one. The trait mods page says how to write against it today; the project board and the pinned roadmap issue #771 hold the work that is still open.
One clean break
A mod is written against one API, mod_api (the crate horizon-mod-api), and the API keeps one hook per
moment. A hook whose NeoForge 26.3 counterpart exists takes its name, side and full contract. Two hooks at the
same vanilla call site, _v1 and _v2 pairs included, are one hook, and the old one is deleted, not kept
beside it. A veto before an action and a notice after it stay separate when vanilla runs code between them. A
hook whose vanilla call site is gone leaves the catalog.
There are no shims: no aliases, deprecated names, fallback paths, version gates or "kept for old mods" branches. An old mod rebuilds, and the compiler says what moved. Removing a path updates every caller, the catalog, these docs, the tests and the sample mod in the same change. A hook with no NeoForge counterpart is judged on its merits like any other.
Every hook is declared once, in the catalog (crates/horizon-mod-vocab/src/hooks/): its name, side, contract,
scope and doc. The generator writes everything repeated from it: the Hooks trait, the part traits, the
Host traits, the dispatch and the reference. A hook is added by declaring it, not by
editing match arms.
The Host is split by side
A hook receives the game through a host, and the host is a small set of traits, so that a call on the wrong side does not compile:
Hostis the shared base: handle constructors, registries and settings.ServerHost: HostandClientHost: Hostare generated from each catalog entry'sside. Only the server writes the world, sotransactionis onServerHostalone.- A hook that fires on both sides receives a typed
HostSideto match on. A hook fired inside a level write, where there is no game to borrow, receives the read-onlyLevelHost(aLevelSide) instead. - Drawing goes through a
DrawContext, a trait object given only to render and screen hooks, beside a&dyn ClientHost.
A method with a natural receiver belongs to a handle, not to the host: EntityRef, LivingRef, PlayerRef,
LevelRef, BlockEntityRef and the other handles carry the per-object methods. A block entity's lock is the vanilla item-predicate compound: lock reads it and set_lock sets or clears it through the transaction journal. replace_data replaces saved data while retaining the entity's identity and position; it does not change its type. Changing type requires the owning block's lifecycle path, as described in transactional block-entity removal. Reads take &dyn Host; writes
take &mut dyn ServerHost. Once a handle method exists, the id-based helper it covers is removed, so there is
one way to do each thing. Attachments live on handles too. Commands, players, messages and scheduling are small
services reached from the host, not methods on it.
A hook fires with the right host for its moment. A client-side decision that vanilla makes inside a client
library is split into decide, fire and apply: the library decides, the game fires the hook with a ClientHost,
and the apply re-checks the decision's preconditions and refuses, never recomputes, if they changed.
Load access belongs to the owning thread
Registration has only a RegistrationContext. Each side then enters Setup, Config and World;
config loading can read settings and use its existing scoped ConfigFiles service, but cannot
acquire world handles, open transactions or call other game operations. Client setup and initial
Client/Common configs finish before title, connection or ordinary runtime callbacks begin, so the
first client pre-tick is already in World even without an open world. Data and asset reloads
temporarily enter Reloading and restore Setup or World when they finish or fail. The catalog marks
published asset/data queries as resource_read: completion hooks can refresh sprite/recipe caches
from the newly published generation without acquiring a world handle or transaction. Server reload
Host reads are restricted to those published facts, registry facts, settings and the mod list;
existing data-context callbacks keep DataLoadContext. A forbidden operation panics naming
the phase. The game alone advances the thread-local boundary, so the window and integrated server
cannot change one another's permissions. Load phases and access
lists the once-per-process, per-world and per-reload moments. No additional FML or intermod bus is added.
Registering behaviour
Choose the entry point by who owns the result. For behaviour attached to an existing block, item, entity
or menu, implement Hooks::register and call the corresponding method on &mut dyn Registrar. For a
subsystem that rebuilds its own table, implement its collection hook. The game owns both the lifetime
of the table and the point at which it becomes visible.
Hooks::register runs once at startup, in mod dispatch order, with a RegistrationContext.
The registrar records the owning mod, checks for conflicting declarations and reports refusals before
installing the table. It accepts registry keys and tags before the final per-game tables are available.
When a game resolves its registries and tags, it binds the Parts to the objects they target. Runtime
hooks then dispatch to the attached Parts. See registry keys
and Parts.
| Behaviour | Entry point | Side and collection time | When usable |
|---|---|---|---|
| Block, block entity, item, entity and menu Parts | Registrar::attach_block_part, attach_block_entity_part, attach_item_part, attach_entity_part, attach_menu_part | Startup on the sides that load the mod | After resolution against the game's registries. Each Part method runs on the side declared for that hook. |
| Capability providers | provide_block_entity_capability, provide_item_capability, provide_entity_capability on the registrar | Startup registration, server provider calls | After the game resolves the registrations. A capability query supplies the holder and a server host. |
| Block entity types, default entity attributes and entity spawn placements | declare_block_entity_type, declare_entity_attributes, modify_entity_attributes, declare_spawn_placement | Startup declarations on the sides that load the mod | When the registered types, defaults and spawn rules are resolved against the content tables. |
| Biome modifiers | declare_biome_modifier | Startup declarations on the sides that load the mod | When the world's biome tables build, with the data-file biome modifiers. Not applied by the game yet. |
| New attribute types | declare_attribute | Startup declarations on the sides that load the mod | When the attribute catalog grows, before entity attribute rows resolve. The mod attributes append after the vanilla ones, sorted by mod id then attribute id — the network id order on every side running the same mods. |
| Game rules and categories | game_rule_bool, game_rule_category | Startup, before the game validates and installs the rule schema | When the game uses the installed schema to load rule values and build the rule UI. |
| Dimension and chunk generation code, structure types, ticket controllers | register_dimension, register_chunk_generator, register_structure_type, register_ticket_controller | Startup declarations, consumed by world setup and generation | When the world resolves its dimension, structure or ticket registrations. |
| Controls bindings | Registrar::register_key_mapping | Startup registration, client consumer | When the client installs the bindings in Controls and restores saved bindings. |
| Component tooltips | Registrar::register_tooltip_appender | Startup registration, client consumer | The item tooltip builder uses the callback for the registered component and placement. |
| Server commands | Hooks::register_commands collects Command usages | Server, when opening a world and on explicit command refresh | After the server rebuilds the command tree. The server owns permission checks and sends the tree to players. |
| Command argument types | Registrar::register_command_argument declares parsing and suggestions | Startup, on the sides that load the mod | Before command trees that use the type are built at world open or refresh; clients that do not know the type complete it from the server. |
| Client commands | Hooks::register_client_commands collects Command usages | Client, at connection start and when the server sends its tree | After the client incorporates them into completion and local dispatch. |
| HUD layers and F3 entries | register_gui_layers and register_debug_entries | Client startup | When the installed HUD order or debug entry list is used for drawing. |
| World-space debug renderers | register_debug_renderers | Client, each refresh of the renderer list | The next debug draw uses the refreshed list. |
| Model layers, colour resolvers, block and item tints, numeric and categorical item model properties | register_layer_definitions, register_color_resolvers, register_block_tint_sources, register_item_tint_sources, register_range_select_item_model_property, register_select_item_model_property | Client startup before resource loading | The client can resolve the declarations when it builds its models and rendering tables. |
| Data map types | data_map_types | Server, at world data load and each reload | After the server publishes the loaded data maps. |
| Recipe property additions | recipe_property_sets | Server data load and client preload, after recipes resolve, and again on reload | When station slot checks use the published sets. |
| Mod configs | register_configs | Config phase on the owning side, once at startup for Client/Common and per world for Server | After the file is read and the mod receives its config loading callback. |
Content mods declare identities and data such as blocks, items, entity types
and menus in data/<id>/horizon/. These declarations supply the game's registry entries, models or
templates and other data. The startup blocks and items hooks can submit the same declaration types
from code and go through the same content checks. Attaching a Part or providing a callback adds behaviour
to an identity. For example, a menu declaration describes its slots, while a MenuPart supplies code for
that menu's lifecycle. Likewise, the item-scoped item_decorations hook draws after the game's default
item icon decorations in slots, the hotbar and other views that use the shared item decoration path.
Tracked gaps in registration
The issues below specify registrations still missing at this revision. Use the existing registration surface for startup declarations and the owning subsystem's collection hook for tables rebuilt during reload. Each builder should collect typed declarations and validate keys before publishing them.
| Builder | Side and contract | Issue |
|---|---|---|
| Data pack registries | Declare the entry codec before server data loading and rebuild the registry on reload. | #811 |
| Attribute types | Declare a namespaced attribute and its value rules before entity defaults resolve. | #812 |
| World generation features | Bind named feature and placement implementations before server worldgen data resolves them. | #815 |
| Particle providers | Bind client provider factories to particle types before the particle engine spawns instances of those types. | #822 |
| Entity spawn placement | Bind the server placement rules and predicates to entity types before natural spawning. | #847 |
| Cauldron interactions | Bind item handlers to the relevant cauldron interaction table before gameplay. | #854 |
| Entity renderers | Bind client renderer factories to the entity types the renderer table will resolve. | #860 |
| Biome colour resolvers | Register named client colour resolvers for block rendering and use them when meshing. | #873 |
| Special item models | Register client special model codecs and bake their renderers when loading item models. | #876 |
| Custom item models | Resolve named client item model codecs during resource decoding. | #877 |
| Custom block models | Resolve named client block model codecs during model loading and baking. | #878 |
| Block-state models | Resolve registered client block-state model types during resource loading. | #879 |
| Animation types | Resolve client animation type declarations when decoding animation JSON. | #880 |
| Skull models | Associate client model factories and textures with skull types before rendering. | #882 |
| Particle groups | Register client render groups before custom particles enter the normal grouping and drawing path. | #883 |
| Render pipelines | Register typed client pipeline resources before a draw can request them. | #884 |
| Sprite source types | Resolve custom client sprite source codecs before collecting atlas sprites. | #885 |
| Texture atlases | Declare client atlas ids and sources so resource loads can stitch and publish them. | #886 |
| Picture-in-picture renderers | Bind client render-state types to renderer factories before GUI extraction uses them. | #887 |
| World preset editors | Bind client editor factories to preset ids before the creation screen offers them. | #888 |
| Entity spectator shaders | Bind client post effects to the types that select them in spectator view. | #889 |
| Client extensions | Associate typed client extensions with item, block, fluid and effect identities. | #895 |
| Fluid models | Resolve client fluid model declarations before rendering the fluid in the world. | #896 |
| Entity render-state modifiers | Register client callbacks keyed to the relevant renderer and apply them during extraction. | #897 |
| Map decoration types | Register server types whose decorations are saved with maps and synced to clients. Reuse the existing client icon draw path. | #898 |
Parts
A per-block, per-item or per-entity moment is a method on a part, as it is a method on Block or Item in
vanilla and NeoForge. A mod attaches parts once, in Hooks::register, and the game asks only the parts
attached to the thing in question. Trait mods, Parts and attachments covers writing
them; the design is this:
- One trait per scope.
BlockPart,BlockEntityPart,ItemPartandMenuPartare generated from the catalog entries whosescopenames them, so a part on a plain block has no tick method to write. - A global hook only where NeoForge has a bus event. Where a NeoForge event is the counterpart for
the override moment, that event is a hook on
Hooks, asked first for everything, and its cancel skips the parts. Where it has none, the moment is a part method alone and has no global form. A mod reacting to its own blocks uses a part; one reacting to every block uses the event. - A part is configuration. It takes
&selfand isSend + Sync, like a vanillaBlock's properties. State belongs to the game, in the block entity's slots, saved compound or attachments, so it saves, syncs and copies with the holder and is never kept in a mod's statics. - The same kind twice on one thing is refused at load, naming the mods, and a part's kind is its type. Parts attach by key or by tag, and the tags resolve against each game's own tags. Block dispatch uses a load-prepared API key lookup, refreshed on tag reload. If no part subscribes for that block, the game skips converting the event's state values and write collections. This per-block filter does not restrict ordinary global hook listeners.
Items, fluids and energy are Parts as well, as capabilities in NeoForge's transfer shape. A capability is a
CapabilityKey (ITEMS, FLUIDS, ENERGY) for a block entity, and an ItemCapabilityKey (ITEM_ITEMS,
ITEM_FLUIDS, ITEM_ENERGY) or EntityCapabilityKey (ENTITY_ITEMS) for a stack or an entity. The game
answers a key for what it owns, a mod's provider answers it for what the mod owns, and the first to answer
wins. Handlers are NeoForge's ResourceHandler<R>, and every write runs in a transaction opened with
host.transaction:
- One journal, in the game. A handler holds no state and no mod writes a journal. A write applies in place; the first write to a storage at each depth snapshots it; a nested commit hands its snapshots to its parent; an abort restores them. Notifications to the world run once per changed storage at the outermost commit.
- One move.
move_resourcesmoves items and fluids between two handlers, each step all or nothing, and energy shares its step. A handler that answers more than it was asked or offered is a mod bug: the move is undone and the game warns once, naming the mod. - Exact units. Fluid amounts are millibuckets. A Fabric amount that is not a whole number of millibuckets (81 droplets) is refused, never rounded.
- Holders have stable identities. An
ItemPlacenames where a stack lives, so a handler inside an item inside a player's inventory changes the stack where it is, journaled.
Systems that run over many things at once can be added beside the part methods later without changing them, because the game owns the data, the data saves and syncs through the attachment codecs, holders have stable identities, and a per-hook part method stays when a system joins it.
One dependency model, native or wasm
A mod can be built two ways, from the same source: native, compiled into the game, or to mod.wasm, which the
game's one sandboxed runtime runs with no rebuild. The only difference between them is the build target.
[dependencies]inhorizon-mod.tomlmeans the same for both: required and optional dependencies, semver against the manifest's versions, dispatch in dependency order, and a mod publishing functions and statics its dependents call. Native links through Cargo; wasm links through the runtime's linker, with one copy of each library shared by its dependents. Both targets use one resolver, keep bundle-order ties, and refuse cycles.- There is no wasm-only packaging unit and no native-only dependency rule. A change to the model lands for both targets together.
- The wasm interface is generated from the same catalog as the
Host, at build time, and never committed: a committed generated file would make every two catalog changes conflict. CI reports the added, changed and removed imports of a change in its job summary, and a coverage test fails on an entry with no binding. - There is no second API. Every
mod_apitype is plain data or a resource, both generated, and nothing in the API is written twice for wasm. An API shape that cannot cross is fixed in the API.
Building a mod to mod.wasm is the how-to. The two targets differ in how a mod is
checked against the game, because they differ in what they link to:
| Native | Wasm | |
|---|---|---|
| Pinned to | the exact game build, in [native] | the interface: the hooks it imports |
| Refused when | the build differs | an import it needs is missing or changed |
A wasm mod keeps running on a newer game while the hooks it imports are unchanged. horizon-mod-api therefore
always builds for wasm32-unknown-unknown: a hook that needs an engine type takes a plain-data type of the
API's own, converted on the game side.
Injections stay, and the API comes first
[[inject]] is the shared native/wasm escape hatch for what the API does not yet reach. The rule is
API first: a mod needing something the API lacks asks for the hook that replaces the injection, and the
injection is the stopgap until it exists. The injections chapter holds the current forms,
the decision ladder and the hook-request format.
The order of the build route
Compiling a native mod against a game already built is the route that makes native mods cheap, and each step rests on the one before:
- The prebuilt game. The game as a dylib with its metadata, built daily and published as prerelease builds, so a native mod compiles against it instead of rebuilding the game.
- Slots. Injection targets compiled into the prebuilt game, so a mixin does not force a rebuild. The generated slot points exist; filling them from a mod follows.
- The internals tier. A per-mod declared list of game internals the prebuilt game exposes. It is native only and logged, and each internal a mod needs is a request for an API.
- The blend. A wasm mod that also carries slot mixins. An API-typed mixin is filled by the
mod.wasmitself, with no native code. A mixin that needs internals ships as source, compiles on install, and marks the package as including native code. The native and wasm parts talk only through interfaces the mod declares, with plain data or resources crossing, so raw internals never reach the wasm side.
A mod that needs a change to a non-slot part of the game still rebuilds the game.
No telemetry
The launcher and the game send nothing about the player or their machine to any server: no performance, crash,
bug or benchmark uploads, and no "I'm playing" ping. Reports stay local files the player can read and share by
hand. A mod API that reported on mods would break this, so none does; the repository's docs/telemetry.md
says what stays local.
What is next
The open work is not listed here, because a list in a page goes stale. It is on the
project board, grouped by phase, and the pinned roadmap issue
#771 gives the direction. A change to the mod API updates its
catalog doc and the pages it affects in the same pull request, so this page and the reference follow the code.
Design: vehicle health layout
Provisional implementation contract for #2741; records the provisional answers A and B, with vanilla default sizing preserved. Approval admits this note only; public counterpart verification remains a shipping gate. Mods supply effective maximum hearts, not rows. The engine prepares one immutable result per visible HUD frame, before reserving space or drawing any status layer. Reservation, vehicle drawing and the player's status layout consume that same result. Cancelling a layer changes drawing only.
Current owners
References are against main at d57e01504e.
crates/horizon-client/src/game/mobs/mounts.rs:951:draw_mount_hudresolvesmount_index, computes capacity, constructs vehicle draws, callskeep_status_rowsbeforegui_layer, then returns capacity even when drawing was cancelled.crates/horizon-client/src/game.rs:8770: initializes the frame's HUD room;game.rs:8867calls mount drawing except for spectators, then passes its returnedvehicle_heartsinto player layout atgame.rs:8903. This shared client presentation route serves single player, LAN and remote servers; dedicated servers draw no HUD.crates/horizon-client/src/game/interface/hud_room.rs:59: reserves status keep-outs from draw geometry, independently of whether the layer later draws.crates/horizon-client-render/src/hud_layout.rs:473: default capacity;hud_layout.rs:478: ten containers per row, starting ath - 39, stepping up ten pixels;hud_layout.rs:674selects food using zero vehicle capacity;hud_layout.rs:704offsets the player's air row using vehicle row count. Player health hearts themselves need no new vertical displacement.crates/horizon-mod-vocab/src/client/hud_layout.rs:143:HealthInputs.vehicle_heartsis the vocabulary crossing to player layout. The exports live incrates/horizon-mod-vocab/src/exports/hud_layout.rs:1.crates/horizon-client/src/game/native_hooks/shared.rs:2202:gui_layerruns preceding mod layers, Pre, conditional drawing, then Post. Today Post runs even when Pre cancels. Preserve that existing behavior pending verified counterpart evidence.
PR #2697 moves vocabulary hud_layout.rs; PR #2586 touches mounts.rs. Re-resolve
these owners after they land; do not create a parallel vocabulary or mount path.
Vanilla and public counterpart evidence
Read vanilla 26.3 with these reproducible queries from the repository containing prepared vanilla resources:
python3 tools/rea_vanilla.py method net.minecraft.client.gui.Hud getVehicleMaxHearts
python3 tools/rea_vanilla.py method net.minecraft.client.gui.Hud getVisibleVehicleHeartRows
python3 tools/rea_vanilla.py callers net.minecraft.client.gui.Hud getVehicleMaxHearts
python3 tools/rea_vanilla.py callers net.minecraft.client.gui.Hud getVisibleVehicleHeartRows
The private Hud.getVehicleMaxHearts(LivingEntity) returns zero for null or a
vehicle not showing health; otherwise min(30, ((int)(maxHealth + 0.5f)) / 2).
The private getVisibleVehicleHeartRows(int) returns ceil(hearts / 10.0).
These are sizing helpers, not public mod hooks. Thirty health therefore means
15 hearts and two rows; 8 current health fills four containers.
Vanilla 26.3 registers minecraft:max_health with default 20.0, minimum
1.0 and maximum 1024.0 health. The prepared vanilla attributes.json
records these as raw f64 bits 4034000000000000, 3ff0000000000000 and
4090000000000000, respectively. These registered bounds are readable through
Horizon's attribute table: crates/horizon-world/src/attribute_health.rs:79
resolves minecraft:max_health, and attribute_health.rs:114 reads its value.
At two health per heart, the allowed effective capacity is 0.5–512 hearts.
The default HUD's 30-heart display cap is not the attribute maximum and must not
restrict mod output.
Public-only checks of the NeoForge GUI layer documentation
and the 26.3 RenderGuiLayerEvent.Pre javadoc were inaccessible. A named sizing counterpart, layer name and cancellation/Post
contract are unverified. No NeoForge source or patches were consulted.
If public 26.3 documentation establishes a counterpart at this sizing moment,
adopt its name, side and full contract before shipping; do not add an alias.
Proposed preparation contract
Provisional name: vehicle_health_layout, client side, with &dyn ClientHost.
This is a sizing callback, without DrawContext or cancellation. Inputs are the
existing lifecycle-owned vehicle handle, current health, actual maximum health,
vanilla default heart capacity and frame dimensions. No second identity generation.
Output is a mutable f64 effective_max_hearts, measured in hearts and initialized
to the exact vanilla default, including zero at maximum health 1.0. Dependency-ordered listeners see prior accepted output; the last
accepted value wins. They cannot independently change rows or real entity health.
Resolve eligibility once after HUD visibility is known and before the first status
reservation or mod-layer drawing. No mount, spectator or hidden HUD prepares zero
without firing. Recheck the selected handle before applying; if retired, refuse
that preparation and use zero for all consumers for that frame, without recomputing.
Cache only for that frame, never across riding changes or HUD visibility changes.
Move preparation into a sibling module; keep oversized game.rs call-site edits
net zero lines.
Validate explicitly supplied callback output at the boundary; an unchanged output retains the previous answer, including a zero vanilla default. Refuse NaN and either infinity, retaining the previous validated capacity; clamp every finite answer to exactly [0.5, 512.0] hearts (equivalently [1.0, 1024.0] health). Zero and negative answers therefore become 0.5; hiding drawing remains the layer Pre hook's job. Apply the same policy after native callbacks and wasm Wire decoding.
Keep fractional capacity separate from whole sprite containers: a half-heart
capacity occupies one container. Compute occupied containers as ceil(capacity)
and rows with vanilla's ceil(capacity / 10.0) arithmetic, once. Thus 0.5 hearts
means one container and one row, while 512 means 512 containers and 52 rows.
Vanilla's current-fill arithmetic uses ceil(health) health points and full/half
sprite comparisons; 1.0 current health paints a half heart in that one container.
This fractional-capacity rounding deliberately differs from the private default
getVehicleMaxHearts integer division quoted above, which returns zero for 1.0
maximum health. Preserve that helper's exact default sizing whenever no listener
changes output. Fractional capacity is reserved for explicitly supplied listener
output; attribute bounds do not impose a minimum on vanilla HUD containers.
Retain vanilla's 30-heart default display cap while allowing mod output to 512.
Clamp displayed current fill to capacity without altering entity health. A
panic/trap discards that listener's output and retains the previous validated
value, using the runtime's existing fault isolation and diagnostics.
The prepared record contains effective hearts, derived rows, selected vehicle and
health snapshot. Generate vehicle geometry and the status keep-out from it; pass its
occupied-container count to HealthInputs, whose integer field yields the
same row count. Retain fractional capacity in the prepared record for fill clipping. No consumer rereads max health or calls sizing
again. Pre and Post may inspect the prepared sizing but cannot mutate it. A Pre
cancellation leaves the keep-out and player placement unchanged. Custom redraws can
use the prepared geometry. Post follows the existing layer dispatcher contract.
Native and wasm use the same catalog declaration and API-owned Wire input/output
records, generated adapters and validation. Unsupported capability registration
must refuse with a named UnsupportedCapability(vehicle_health_layout) error;
never silently register an inert callback. The no-subscriber path performs one
O(1) subscriber-presence check, uses exact vanilla sizing without an unconditional half-heart minimum and creates no event,
serialization, listener scan or extra allocation. Existing draw allocation is
unchanged. With subscribers, dispatch only to the indexed subscriber list.
Acceptance matrix
For air-row examples let GUI height be 240. With a vehicle the food row is skipped,
so the air baseline is h - 49 = 191, and the air row is drawn at 191 - (rows - 1) * 10.
| Case | Prepared hearts | Rows | Observable expectation |
|---|---|---|---|
| No listener or unchanged listener output, maximum health 1.0 | 0 | 0 | No vehicle containers; vanilla food and air layout |
| No listener, maximum health 30, current 8 | 15 | 2 | 15 containers, 4 full; vehicle rows at 201 and 191; air at 181 |
| Same mount, mod supplies 4 | 4 | 1 | 4 containers, all full; air at 191; one-row reservation |
| Mod supplies 10 | 10 | 1 | 10 containers at 201; air at 191 |
| Mod supplies 11 | 11 | 2 | 10 containers at 201, 1 at 191; air at 181 |
| 11 hearts, Pre cancels drawing | 11 | 2 | No vehicle sprites; reservation unchanged; air remains at 181 |
| Listener explicitly supplies 0.5 at 1.0 max/current health | 0.5 | 1 | 1 container with half-heart fill; air at 191 |
| Maximum: 1024.0 health, mod capacity 512 | 512 | 52 | 512 containers: 51 rows of 10 and final row of 2; air at -319 |
| Mod supplies 0 | 0.5 | 1 | Clamp to minimum; 1 container; air at 191 |
| Mod supplies -1 | 0.5 | 1 | Clamp to minimum, for native and wasm |
| Mod supplies 2000 | 512 | 52 | Clamp to maximum; same reservation and player air placement as 512 |
| Mod supplies 512.5 | 512 | 52 | Clamp to maximum |
| Mod supplies NaN after valid 4 | 4 | 1 | Refuse answer; retain 4; air at 191 |
| Mod supplies positive or negative infinity after valid 4 | 4 | 1 | Refuse answer; retain 4; air at 191 |
| Two listeners supply 4 then 11 | 11 | 2 | All three consumers use 11, not the intermediate 4 |
| Listener supplies 4 then another panics/traps | 4 | 1 | Prior validated capacity retained |
| No mount, spectator, or hidden HUD | 0 | 0 | No sizing dispatch or vehicle reservation |
Test malformed wasm output followed by the built-in layout, frame-to-frame riding changes, one invocation per frame, and no-listener equivalence.
Stages
- Verify the public NeoForge counterpart and settle the provisional name. Use the registered attribute bounds above for implementation.
- Add the shared preparation record, catalog/Wire contract and both adapters; update the three consumers together, after reconciling #2697 and #2586.
- Add focused layout and native/wasm dispatch tests for the matrix, then regenerate hook and boundary documentation. This proposal itself changes documentation only.
Open questions
- Does public NeoForge 26.3 name this sizing moment? Recommendation: retain the provisional name only if no counterpart exists; public evidence decides naming and any required layer dispatch correction.
Design: per-type batched block entity ticks
Provisional; implementation waits for a maintainer decision. This answers #2759. The recommendation is contiguous same-type runs in the existing ticker order, with each entity committed before the next one starts. This reduces outer calls where runs exist; it does not promise one call per type across a whole level.
Existing moments and verified owners
The existing plural block_entity_ticks is not a batched execution hook.
crates/horizon-mod-vocab/src/hooks/block_entity_ticks.rs:4 declares a server,
read-Host eligibility query, keyed by block, asked for block states at world open.
BlockEntityTicksEvent.ticks selects whether a ticker exists; it contains no list
of entities. The Part example at
crates/horizon-mod-vocab/src/parts/block_entities.rs:334 sets it to true, then
implements the separate mutable individual tick. The adapter is
crates/horizon-client/src/game/native_hooks/base.rs:1083, called by
crates/horizon-client/src/game/native_hooks/shared.rs:3112 (native_ticks).
Reuse this eligibility decision and its frozen state tables. Do not turn it into
a second execution callback, or introduce a parallel batch moment.
Extend the existing singular execution moment instead:
crates/horizon-mod-vocab/src/hooks/block_entity_tick.rs:4 and
crates/horizon-client/src/game/native_hooks/base.rs:1162.
Replace its individual event/dispatch shape with the approved run cursor shape
in one clean break, updating all callers, generated catalog/coverage, examples
and the machine sample together. No legacy individual callback or compatibility
adapter remains alongside it. A run of length one uses the same listener.
It remains a BlockEntityPart listener with &mut dyn ServerHost on both targets.
| Route / owner on inspected main | Integration requirement |
|---|---|
crates/horizon-client/src/game.rs:8564 | Server world pass calls the common ticker loop; integrated and dedicated execution use this route. Remote clients do not run authoritative saved-data ticks. |
crates/horizon-client-render/src/world.rs:502 | Supplies the ordered ticker snapshot. Do not regroup the level by type. |
crates/horizon-client-render/src/block_entities.rs:750,787 | Pending additions enter the list at snapshot acquisition; validate each live slot immediately before use. Ticker slots are not durable holder identities. |
crates/horizon-client/src/game/blocks/block_entity_ticks.rs:45,60,91 | Preserve chunk ticking, sleep, liveness and border checks; replace only the mod-entity dispatch branch. Built-in ticks remain at their existing positions. |
crates/horizon-client/src/game/native_hooks/shared.rs:3285,3313 | Current take/placeholder/put-back route must become holder-safe per-entry processing. It currently marks a restored entity's chunk modified unconditionally. |
crates/horizon-client/src/game/modding/api_values.rs:1934,1866,1899 | API conversion and checked write-back owners; reuse validation rather than adding an unchecked delta route. |
crates/horizon-mod-api-gen/src/dispatch.rs:280,447 | Existing Part dispatch and generated Wire event machinery own the new shape for both targets. |
crates/horizon-mod-api-gen/dispatch-tests/src/fixtures/stateful_block_entity.rs:1 | Existing factory/eligibility fixture is a model for dispatch ownership, not evidence of batched execution. Extend generated fixtures for the new execution shape. |
crates/horizon-client/src/game/blocks/spill.rs:1303 | Direct tick invocation is in a regression test. Update it to execute a singleton run through the same owner. |
The loop also invokes built-in hoppers, furnaces and other vanilla tickers.
Separate sculk passes (crates/horizon-client/src/game.rs:8565) and client
animation work are outside this mod saved-data listener; do not move them into
it. Implementation must audit all generated callers and any newly added routes.
These anchors identify present owners, not already implemented guarantees.
Vanilla and NeoForge evidence
Inspect the pinned vanilla 26.3 bytecode reproducibly with:
python3 tools/rea_vanilla.py method net.minecraft.world.level.Level tickBlockEntities
python3 tools/rea_vanilla.py method net.minecraft.world.level.chunk.LevelChunk.BoundTickingBlockEntity tick
python3 tools/rea_vanilla.py method net.minecraft.world.level.chunk.LevelChunk.RebindableTickingBlockEntityWrapper tick
Level.tickBlockEntities appends pending tickers before iteration, removes
removed entries as it reaches them, and otherwise calls each eligible ticker in
list order when normal ticking and shouldTickBlocksAt permit it. New pending
entries added during iteration wait for a later pass. The bound ticker checks
removal, level presence, chunk ticking and the current block state's validity
before invoking the ticker. Exceptions produce a ticking-block-entity crash
report. The rebindable wrapper’s tick delegates to its current ticker. These
observations support ordered processing and fresh checks, not a whole-level
type grouping. The actual rebind and replacement callers remain unverified
and must be checked before implementing replacement parity; a replacement must never receive an old
entity's saved-data reply.
The public NeoForge block entity guide
retrieved for this note is labelled 26.1. It describes EntityBlock#getTicker
returning a per-entity BlockEntityTicker and recommends caching expensive work.
Its storage section
requires setChanged to mark the chunk for saving. This is contextual evidence,
not a 26.3 batching contract. The 26.3
BlockEntityTicker javadoc was inaccessible. NeoForge 26.3 batching, limits and fault behavior remain
unverified; no counterpart or equivalence is claimed. Only public docs and
Javadoc were consulted; no NeoForge source or patches were read.
Proposed execution contract
Choose A: contiguous runs, not B: one call per type across the level.
For [A1,A2,B1,A3], dispatch [A1,A2], tick B1, then dispatch [A3].
An ineligible or different-type slot ends a run; do not scan ahead to merge runs.
Grouping by type would move A3 before B1 and change neighbor reads, hopper
chains and observable side effects. There is no batching measurement justifying
that change. Preserve dependency order of listeners within each entity:
A1 listener L1, A1 L2, A2 L1, A2 L2. A naive outer call to L1 for all entries
followed by L2 for all entries violates this rule. Initially batch only runs
with one resolved execution listener; with multiple listeners, use singleton
runs through the same cursor contract until a scheduler preserves that order.
A precomputed list of snapshots and an eventual list of replies cannot provide
per-entity visibility. Recommend a host-driven, sequential cursor: the outer
listener receives the run context, asks for its next entry, processes it, then
calls a checked done(result) operation before requesting another entry.
done validates and completes that entity's action, and only then may next
materialize the next snapshot. This import costs a crossing per entity in wasm;
benchmark the net saving rather than advertising elimination of all crossings.
Only one entry may be outstanding; double completion, wrong run tokens and
out-of-order identities refuse without mutation. Ending a call with an
unfinished entry aborts that entry; it must not silently mark it complete.
Each snapshot carries the existing scoped lifecycle holder, type key, position,
current block state and saved-data revision. The revision is not the lifecycle
generation. Read it beside the holder; cache reuse requires equality of holder,
revision, position and state. Recommend a full saved-data snapshot on cache miss
and a compact validity record on hit. NeedFull supplies that same entry's full
snapshot once without advancing the cursor or executing a second tick. Recheck
versions after host writes; a stale reply refuses rather than recomputing or
writing over another entity. The saved-data revision owner is unresolved: #2794 is the outside-engine
JVM/language-host workstream, not this prerequisite. Identify the actual revision
owner and audit every saved-data and inventory mutation path before implementation;
cache reuse is gated on complete mutation coverage.
The entity action covers listener Host writes and checked saved-data apply.
A1's commit is visible to A2, including neighbor reads. Do not hold a mutable
engine entity borrow across dispatch or remove the entire run into placeholders.
Revalidate eligibility, residency, holder and state at each next and done.
A1 removing A2 means no A2 callback; unloading its chunk means no subsequent
callback there. A replacement invalidates the old reply and cache match. New
pending tickers wait until the next pass; wrapper rebinding must preserve the
vanilla distinction between updating a ticker and appending a new one.
Reuse crates/horizon-client/src/game/runtime/transactions.rs:193,212,293
for action savepoints and block-entity journaling, and the
Part instance state contract for holder identity and
state. During lifted outer actions, per-entry commit merges into the parent:
later entries see tentative earlier writes, but retirement, irreversible effects
and journal publication wait for root commit; root abort restores all entries.
Do not open a batch-wide transaction in the normal tick path. Refuse saves while
an action savepoint is open, so today's temporary placeholder cannot omit an
entity from a save. This gate and holder-safe access are prerequisites, not
claims about the current singular callback.
Write-back, caches and trust
Results are Unchanged, a validated delta, or NeedFull. Deltas contain changed
and removed saved keys plus checked item/data-slot updates; they cannot edit
identity or type. Reject duplicate/conflicting keys, invalid slots/stacks,
wrong holders, stale revisions and malformed Wire before any apply. Apply the
whole entity result atomically through the existing write-back validation.
Deleting absent key K or setting V=5 when V is already 5 is unchanged: no dirty
mark solely for ticking or receiving a delta. A real write marks the owning
chunk and emits required container/comparator effects once through existing
owners. A listener's independent Host write still has its own normal dirty
effects. This overlaps #1780;
this note specifies the desired result, not a general write-back implementation.
API-owned Wire records must represent cursor tokens, holder/revision metadata,
full snapshots and results; no engine NBT types, guest pointers or native-only
Any cross the boundary. Native and wasm expose the same listener, ordering,
snapshot fields, validation and failure outcomes. Unsupported record/state Wire
capabilities refuse registration/load with the proposed named refusal
UnsupportedBatchTickCapability(type, capability) before ticking.
Bound lengths, total encoded bytes, nesting, cursor lifetime and host operations using shared Wire/NBT and runtime fuel/deadline budgets on both targets. Require at most one full-snapshot retry per entry. Chunk long runs at existing transport limits without reordering; batch capacity must not cap the world's vanilla entity count. A full snapshot too large for one frame needs the approved shared bounded transfer route, never truncation or silently skipping that entity. Exact numeric budgets and that route require approval before code.
On native unwinding or a wasm trap/interrupt, abort the outstanding entity's
savepoint and invalidate that cache entry. Keep already root-committed A1.
Use the runtime's existing fault policy to disable/stop the failing listener;
do not retry it later in this run. Other listeners and built-in entries follow
the existing dispatch policy. Process-aborting native panics are unrecoverable.
Wasm stop/disable owners include crates/horizon-mod-wasm/src/runtime.rs:426,631;
precise native recovery and later-listener continuation need an explicit audit
before implementation claims parity. A lifted root abort can still undo A1.
A mod may keep a bounded, evictable cache in its own memory, never saved or synced; losing it only causes a rebuild. Persistent typed per-holder data must use #796/#781's attachment-backed contract. No new engine cache store or generation is introduced. Invalidation notices may be an optimization of existing holder lifecycle notices, never necessary for correctness. A 4096-entry LRU is the external experiment's policy, not an engine or vanilla limit.
Cost and measurement input
Resolve subscribers by type at registration. With no subscriber, one O(1) type-table lookup selects today's path: zero batch events, no grouping, no entity snapshots/copies and zero allocations beyond today's ticker machinery. Do not scan every mod or serialize data to discover whether someone listens. Subscribed cost is linear in processed entries and changed bytes, with bounded buffer reuse; alternating types may offer no batching gain.
reservedbytes' measurements on #2759
are external input, not re-measured here. Their 1000 cooking entities ran on
a Ryzen 9 9950X, Ubuntu 26.04, pinned to eight physical cores on a loaded box
(load 40–58), fork 964de9d3f, Wasmtime 50.0.1, a custom single-store runtime
rather than the group linker, four interleaved runs per side. Retaining guest
objects changed tick mean 125.3 to 94.4 ms (median across runs), guest handler
103.9 to 74.6 ms, and collection 7.6 to 13.5 ms per tick. Earlier-build bytes
in/out changed 371/287 to 21/24 per entity; in-game host calls 46 to 33, offline
54 to 39. Offline interpreter work changed 774k to 591k operations; full rebuild
cost about 260k and delta comparison added about 127k. Post-collection live heap
rose 1.1 to 6.2 MB. All results still used one call per entity; batching was
not measured. They motivate cache experiments and GC accounting, not a claimed
Horizon batching speedup.
Existing measurements are
crates/horizon-mod-wasm/tests/bench.rs:84 (1000 fake in-memory dispatches) and
crates/horizon-client/src/game/modding/native_live_holder_tests.rs:46 (real
Game holder path). Neither establishes this proposed batching's performance.
Measure no-listener, singleton, long-run and alternating-type scenes on both
targets, including collection, memory, payload bytes, host calls and multi-listener
order. Do not promise an improvement until those results include the cursor cost.
Batching ships only with a measured total tick win on a real prepared-data scene in the optimized profile, including cursor crossings and GC. The no-mod tick must remain within measurement noise, and the build must change no saved data. These are shipping conditions, not optional performance claims.
Acceptance matrix
These are proposed implementation tests with literal expectations, not checks executed for this documentation-only change.
| Fixture | Expected result |
|---|---|
[A1,A2,B1,A3], only A subscribed | Two outer calls: [A1,A2], then [A3]; B1 ticks once between them. |
| A1 commits V=5; A2 reads V in the same run | A2 reads 5, not the pre-run value 0. |
A1 removes A2 before next | A2 callbacks=0; no A2 snapshot/reply applied. |
| A1 unloads A2's chunk | A2 callbacks=0; batch does not load or pin the chunk. |
| Old A2 holder replaced, late old reply sets V=9 | Refusal; successor's initial V=0 remains 0; old cache cannot match. |
| A1 adds A4 during iteration | A4 callbacks this pass=0; next eligible pass=1. |
| Saved V=5; Unchanged, then delta V=5, absent-key deletion | Zero new dirty marks or comparator notices from these results. |
| Saved V=0; delta V=5 and remove existing K | Saved V=5; K absent; owning chunk marked once. |
| No subscriber, 1000 entities | 0 batch events; 0 extra allocations, grouping passes or serialized bytes. |
| A2 traps after A1 commits V=5 | A1 remains 5; A2 has 0 committed writes; no later invocation of failed listener in this run; built-ins follow runtime policy. |
| Lifted root starts V=0, A1 completes V=5, root aborts | V=0; 0 published retirement/sync effects. Save while open refuses. |
| L1 and L2 subscribe to A1/A2 | Singleton scheduling yields [A1:L1,A1:L2,A2:L1,A2:L2]. |
| NeedFull twice, duplicate done, wrong token or bad slot | Refusal; entry advances 0 times until valid completion; saved V=0 stays 0. |
| Native and wasm process identical valid delta V=5 | Identical saved V=5, dirty flags and listener order. |
| Missing Wire capability on type A | Registration refuses with UnsupportedBatchTickCapability(A, capability); 0 ticks. |
Proposed implementation stages
No Rust, runtime promise or benchmark result is added by this note.
- Approve ordered runs, cursor/per-entity commits and multi-listener scheduling; complete wrapper and public NeoForge 26.3 verification.
- Audit revision mutation coverage, holder-safe live access, save refusal, rollback and fault continuation with the lifecycle/write-back owners.
- Specify API-owned Wire records and budgets; update the existing singular hook, generator, adapters, all callers and coverage in one clean break while retaining the plural eligibility moment.
- Implement and test native/wasm matrix cases through the real holders and transaction owners; integrate changed-only apply with #1780.
- Measure both existing benchmark paths and real ordered world scenes, including GC and unused-path costs, before claiming batching support.
Open questions
- Execution shape: approve the cursor and singleton scheduling for multiple listeners. Recommendation: preserve exact order even at reduced batching gain; an eventual reply list cannot satisfy immediate visibility.
- Revision completeness: identify the actual saved-data revision owner and coordinate #1780; #2794 does not own revisions. Recommendation: require saved-data/items revision coverage and atomic checked apply before enabling cache reuse; never treat generation as a revision.
- Replacement and faults: confirm wrapper rebinding and native recovery, plus whether remaining healthy listeners continue after a fault. Recommendation: use the existing lifecycle and runtime policy, with no retry of a stopped listener and no stale successor writes.
- Transport capacity: approve numeric shared limits and full-snapshot transfer. Recommendation: bounded streaming/chunking that preserves vanilla capacity; no arbitrary entity-count cap or truncated data.
- Benefit gate: recommendation: require measured total tick improvement with the per-entry import and GC included before enabling a performance claim. NeoForge 26.3 equivalence remains unverified until public evidence is available.
Design: Source-validated guest point artifact
Provisional proposal for #2553.
Docs only; this is not approval to skip source validation or a speedup claim.
The proposed answer is conditional: an SDK-produced, identity-bound artifact can
replace repeated discovery only when it carries the complete validated point,
private-type and site input. Keep the full-source generator as an independent
check. A supplied --catalog alone never authorizes this route.
Measure actual-mod cold builds after #2596's interface cache first. Do not build or ship an artifact on an estimate; proceed only after the measured post-cache baseline demonstrates worthwhile remaining work.
What remains after the interface cache
#2552 and #2596 are merged context. The latter already caches source interfaces locally and preserves unchanged output timestamps. Repeating the original uncached premise would overstate the remaining problem. Verified owners on the current source tree:
| Route | Owner and observed behavior |
|---|---|
| Guest point build | crates/horizon-mod-points/build.rs:1: always calls generate_points; errors currently terminate the build through expect. |
| Cached generator | crates/horizon-mod-api-gen/src/build.rs:223: enters the interface cache; :227 tracks game, point and site sources, reads private input before selection, discovers points and sites, then parses/formats generated Rust and writes only changed output (:258). |
| Cache admission | crates/horizon-mod-api-gen/src/interface_cache.rs:17: directory keyed by generator executable bytes; :36 keys entries by source path and bytes. It verifies the summary digest and parses it; invalid summaries regenerate. Function bodies are stripped on cache creation. This is disposable local acceleration, not authenticated SDK evidence. |
| Private inputs | crates/horizon-mod-api-gen/src/points/source.rs:37: reads staged source-points.toml; ordinary source trees have no private rows. These rows include generated items, lexical module and facade export information (:7). |
| Discovery and types | crates/horizon-mod-api-gen/src/points.rs:922: resolves Wire types, excludes private targets from public discovery and scans source. |
| Selection and identity | crates/horizon-mod-api-gen/src/points.rs:1166: retains all public record shapes and IDs, limits guest codecs/exports by selection, and computes POINT_LAYOUT across the full public point set (:1205). Selection is not permission to renumber IDs or drop layout inputs. |
| Site discovery | crates/horizon-mod-api-gen/src/sites/stage.rs:7 and :50: tracked inputs and generated site IDs; these also belong in artifact validation. |
| Wasm registration/package | crates/cargo-horizon-mod/src/wasm.rs:248 and wasm/group.rs:297: catalog feeds registration preparation. wasm/group.rs:637 passes selected names to compilation; :569 binds the emitted points metadata digest into the package. None replaces source-derived Rust definitions. |
A target-cold build with an empty OUT_DIR must still create these cache entries.
A cache hit still reads/hashes inputs, parses smaller summaries, constructs point
and private metadata, discovers sites and emits/parses/formats Rust. Source
traversal, generator compilation and downstream guest compilation remain possible
costs. Their current proportions have not been measured here. Cache reuse across
build-script executables or fresh targets must be measured, not assumed.
Vanilla 26.3 is the gameplay specification; this Rust SDK generation facility is Horizon-specific and changes no vanilla call site. No vanilla declaration is claimed as a point-artifact contract. The NeoForge public documentation separately documents its modding API and Gradle toolchain; no equivalent 26.3 point/type/site artifact contract was verified. The public 26.3 javadoc could not be fetched (HTTP 503). NeoForge limits and equivalence remain unverified; public documentation is required before adopting any such contract. No NeoForge source or patches are inputs to this proposal.
Proposed contract and alternatives
Prefer a generated input artifact consumed by the existing validator and emitter,
not arbitrary precompiled Rust supplied by a mod. The SDK producer runs full
source validation once against its exact staged source. Preserve canonical point
order, signatures, host side, head/return behavior, API and internals capability
refusals, private lexical types/fields/exports, site definitions and IDs, and the
complete inputs to layout computation. Use the existing API-owned representations
and one explicit Wire derivation; do not add a second serializer or substitute
internals access when an API capability is unsupported. Carry existing refusal
reasons through artifact generation and refuse unsupported selected capabilities
at registration with a named diagnostic.
A safely narrowed scanner is the alternative. It must resolve the complete transitive module/type closure, imports, target-dependent paths, private lexical scope, global point order and sites before pruning work. Selecting a single function name is insufficient: unselected records still contribute public shape and layout. Recommend retaining full scanning until a closure proof and equivalence fixtures establish safe narrowing. Both candidates use the same emission rules; the independent check bypasses artifact loading and the disposable interface cache.
The authoritative identity is a canonical manifest produced alongside the SDK, anchored in the chosen engine distribution or locally validated source tree, not an engine ID asserted inside mod input. The trusted manifest comes from the Nightly build and is signed with the same key as that build; verify that signature before trusting artifact digests. Bind:
- Exact engine build and staged-source identity, including private staging plans, source bytes and normalized repository-relative paths; directory membership, additions and deletions are inputs too.
- Generator/parser and artifact schema revision, API/Wire declarations and signatures/layout inputs, point/internals catalog bytes and site definitions.
- Canonical selected names (including head/return distinction), target/cfg/features, toolchain and relevant build/profile settings. A reusable complete SDK payload may serve several selections, but each derived build admission binds its exact set.
Use content digests rather than mtimes. Check artifact availability, bounded header and expected identity before decoding its payload. Verify payload length and digest against trusted manifest data, then run structural and semantic validation; a self-reported checksum does not authenticate a modified payload. A local source edit invalidates the SDK identity even if the release name is unchanged. For local source builds, full regeneration establishes the new identity. For an SDK-only build without matching source, refuse with the mismatched field and rebuild/reinstall guidance. Missing, stale, unknown-schema or corrupt artifacts may regenerate only from the exact matching available source through the current full generator. This is recomputation of current inputs, not an old-mod compatibility path; never silently accept stale definitions or omit a selected row.
Mod inputs, selection strings and artifact bytes are untrusted. Before allocation,
require bounded file size, row counts, string lengths, nesting and cumulative
payload bytes; reject duplicates, unknown selected names, invalid paths and
out-of-range IDs. Do not execute artifact Rust or callbacks during admission.
Proposed diagnostics are ArtifactIdentityMismatch, ArtifactCorrupt,
ArtifactLimitExceeded and SelectedPointUnsupported (names subject to review).
Malformed input returns a build error; internal generator panics abort that build,
never produce a partial artifact. Publish only complete output atomically.
Native and wasm keep one dependency and api/internals contract. Native retains exact engine-build loading; wasm retains interface linking. Artifact build identity is stricter provenance for generation, not a new wasm runtime exact-build restriction. No new Host, callback, lifecycle holder or generation counter is introduced. No world mutation, journal, retirement or save boundary is involved. Admission happens during builds; unused feature cost is zero runtime work, including zero tick scans, allocations and no-listener dispatch changes.
Packaging and module identity
Coordinate packaging with open #2815.
Its reviewed diff adds versioned mod-catalog.json to SDK/source staging, with
schema_version, api_version, hooks and public point descriptions. It is a
discovery surface, not proof of complete private layouts or site definitions.
Do not overload it into an executable generation contract or assume it contains
private source plans. Recommend a sibling generation artifact referenced by a
trusted SDK manifest, owned by the same staging pipeline. Avoid a committed
shared hash that every point change must edit; regenerate and compare in drift
checks, with feature-scoped fixtures.
Open #2697 overlaps generator
build.rs, points.rs and points/source.rs; open
#2524 overlaps build.rs.
Reconcile their final ownership before code work. The interface-cache file and
point build script are not touched by those reviewed diffs. This note reserves
no implementation files.
Byte-identical module identity is the default admission policy. Refuse a
mismatch with a diagnostic naming its reason and the differing identity field;
never silently accept it on an estimate of equivalence.
Identical effective inputs must yield byte-identical generated definitions,
point/site IDs, layout values and module identity between routes and across
repeated runs. Preserve current path remapping and compiler metadata handling
(crates/cargo-horizon-mod/src/wasm/group.rs:625). Compare generated Rust,
embedded identities, package points digest and module bytes separately; a matching
POINT_LAYOUT alone is insufficient. Adding an artifact dependency or changing
build-script inputs can itself change compiler/build identities. Any unavoidable
difference needs an explicit explanation, loader/linker compatibility evidence
and maintainer approval before adoption; never normalize away the difference in
the test or repeat the rejected profile-override experiment as a solution.
Acceptance and measurements
These are proposed literal fixtures, not executed results.
| Case | Required result |
|---|---|
| Full fixture: 2 point records, 1 private type, 3 site IDs | Both routes discover exactly 2/1/3 entries and identical validated signatures/layouts. |
| Select one point of two | Exactly 1 selected guest point export; invalid selected signature refuses; 0 silently omitted selected records. Retain the full route's unselected public shapes and stable IDs/layout inputs. |
Change private field u32 to u64; engine A to B; selection ["p"] to ["q"] | Each old admission is refused or regenerated before linking; 0 stale admissions. |
| Artifact truncated to 0 bytes or 1 payload byte modified | 0 successful stale loads; explicit diagnostic or full-source regeneration. |
| Identical inputs twice, then compare full and artifact routes | Byte-identical definitions and module identity; any intended difference is named and approved first. |
| Missing artifact/source; unknown schema; oversized payload; duplicate or unknown selected name | Refusal before linking, no partial output or capability downgrade. |
| Native and wasm, API and internals | Same applicable validation/refusal behavior; no new runtime restriction or dependency model. |
Measure an actual mod build with representative selected points and private type/site use, after #2596. Run 3 cold actual-mod builds per route: current cached full generator, artifact candidate, and narrowed scanner only if implemented. Hold engine, source, selected names, toolchain, target/features, profile, machine and parallelism constant. Define cold as a fresh Cargo target and generator-interface cache for each run; report filesystem/OS cache state and reuse of downloaded sources separately. Report all 3 total wall times and all 3 generation times, not just averages. Also report artifact production time/size, admission/hash/decode, source discovery, emission/formatting and guest compilation time so moving work to SDK production is visible. A separate warm-cache series characterizes #2596; it must not be mixed into the cold series. Check equivalence before interpreting timings. There is no speed threshold until evidence supports one, and historical fixture timings establish no actual-mod speedup.
Proposed stages and open questions
Stages: first collect the post-cache baseline and identity
inventory; then agree manifest/authentication and limits with SDK staging owners;
then implement artifact production/admission using existing validation/emission;
finally run equivalence, corruption and actual-mod measurements before deciding
whether to enable the route. Put future logic/tests in separate modules; no
growth of the over-limit crates/horizon-client/src/game.rs for instrumentation.
- Trust anchor and packaging: recommend a sibling artifact authenticated by the engine SDK manifest; agree its manifest owner and local-source verification procedure with #2815 before implementation. A payload digest alone is insufficient.
- Concrete resource limits: recommend finite byte/count/depth budgets derived from the complete current SDK plus measured headroom; choose literal limits before shipping. No vanilla capacity is reduced by this build-only proposal.
- Identity differences: recommend exact preservation by default. If artifact integration changes compiler identity despite equivalent output, obtain a maintainer decision with the measured native/wasm linking consequences.
- Does this remove enough work? Recommend measuring first and retaining full generation if admission plus remaining compilation erases the benefit. Safe source narrowing remains conditional on closure correctness, not a promised win.
Design: typed cauldron interactions
Provisional contract for #1323 and parent #854. This note implements no API. Typed registration, handler shape, prediction and atomicity require maintainer approval before code. The merged #1325 table and #1390 journal are foundations, not a mod registrar.
Vanilla and public counterpart
Reproduce the vanilla inspection with python3 tools/rea_vanilla.py class net.minecraft.core.cauldron.CauldronInteractions, then method queries for bootStrap, addDefaultInteractions, fillBucket, emptyBucket, fillWaterInteraction, fillLavaInteraction, fillPowderSnowInteraction, isUnderWater, dyedItemIteration, bannerInteraction and shulkerBoxInteraction. Query net.minecraft.core.cauldron.CauldronInteraction.Dispatcher get and callers of net.minecraft.core.cauldron.CauldronInteraction interact too. These are pinned vanilla 26.3 bytecode queries; no foreign implementation is used.
Dispatcher.get selects the first matching tag, otherwise the exact item, otherwise DEFAULT. Returning TRY_WITH_EMPTY_HAND from the selected tag handler does not resume the item lookup. All registrations in bootStrap and its default-registration helper are covered below:
| Dispatcher | Registered inputs and behavior |
|---|---|
| All four: EMPTY, WATER, LAVA, POWDER_SNOW | Water bucket replaces with water level 3 and returns bucket. Lava bucket replaces with lava and returns bucket; powder snow bucket replaces with snow level 3 and returns bucket. Lava/snow under water return CONSUME without changing anything. |
| EMPTY additions | Water potion only: water level 1, glass bottle returned; other potion contents pass. |
| WATER additions | Empty bucket succeeds only at level 3, leaving empty cauldron and water bucket. Glass bottle returns a water potion and lowers one level. Water potion below level 3 raises one level and returns glass bottle; full or non-water potion passes. |
| WATER tag | minecraft:cauldron_can_remove_dye: absent DYED_COLOR passes terminally; otherwise removes that component and lowers one level. This is the only tag branch registered by bootstrap. |
| WATER item collections | Every item in Items.BANNER removes the last pattern from one banner, passing if there are no patterns; every item in Items.DYED_SHULKER_BOX returns one uncolored shulker preserving contents/components. Both lower one level. Horizon expands these to the 16 vanilla colors. |
| LAVA addition | Empty bucket always returns lava bucket and empty cauldron. |
| POWDER_SNOW addition | Empty bucket returns powder snow bucket only at level 3, leaving empty cauldron. |
Lowering level 1 gives an empty cauldron. Bucket and bottle exchanges use ItemUtils.createFilledResult; washing uses its non-limited-creative form for banner/shulker. Preserve survival consumption, creative inventory deduplication where enabled, returned-stack components, inventory insertion and leftover drops. Washing has its cleaning statistic and BLOCK_CHANGE from lowering, with no fluid sound/event. Transfers have their existing statistics, sound and FLUID_PLACE/FLUID_PICKUP; a bottle lowering also has BLOCK_CHANGE. Successful handlers check the same guards on the client but mutate only on the server. This is why a prediction result and an authoritative application are separate operations.
The public NeoForge cauldron documentation describes RegisterCauldronInteractionEvent.Dispatcher on the mod event bus, an identifier for a dispatcher, Interaction registration by dispatcher and item, and registerToAll. Its 26.1 migration primer describes tags preceding items. The accessible documentation is labeled 26.1, not 26.3. Exact NeoForge 26.3 duplicate policy, overlapping-tag order, reload lifetime, side behavior, prediction and rollback guarantees remain unverified; no 26.3 Javadoc contract was established. No NeoForge source or patches were read. The Horizon rules below are proposals, not claims of NeoForge parity.
Verified owners and routes
These file:line anchors describe the inspected base and must be checked again before implementation.
| Route | Current owner and required integration |
|---|---|
| Shared vanilla dispatch | crates/horizon-client/src/cauldron.rs:191 private Action; :206 four maps; :242 interact; :265 terminal tag-first branch; :292 item lookup. Replace the private fixed dispatcher with one resolved dispatcher shared by vanilla and mods. Do not add a second general block-use hook. |
| Shared prediction result | crates/horizon-client/src/cauldron.rs:396 outcome runs interact with a dry player. Replace mod execution here with the typed read-only prediction operation. |
| Authoritative player use | crates/horizon-client/src/game.rs:7234 calls use_on_cauldron. game/blocks/cauldrons.rs:28 owns application, with immediate :40 and transactional :79 paths. Every selected mod handler must open an action transaction, including ordinary clicks that currently use the immediate path. |
| Target lookup | crates/horizon-client/src/game.rs:12338 cauldron_at currently filters a name suffix. Resolve registered BlockKeys instead so custom blocks need no naming convention. |
| Server connections | crates/horizon-client/src/game/network/lan_actions/interaction.rs:241 guest_use_item_on reaches use_item under as_guest_at. Both hands, integrated single player, LAN and dedicated server use this authority route. Preserve reach, protection, spectator and secondary-use guards. |
| Mod/fake-player action | crates/horizon-client/src/game/modding/native_server.rs:771 PlayerAction::UseOn calls guest_use_item_on at :778. Same dispatch, player identity and rollback; no separate fake-player implementation. |
| Remote prediction | crates/horizon-client/src/game/network/remote/mod.rs:8901 cauldron_outcome; :9307 remote_cauldron_reacts; definition :9385, dry interact :9430. Today the latter proposes blocks and sound with a copied hand/inventory. Both must select the same handler and stop normal item use on Success/Consume. |
| Journal | crates/horizon-client/src/game/runtime/transactions.rs:192 open; :239 nested close; :426 deferred drops; :437 journal_set_block; :448 deferred effects; :512 root commit. Extend these owners rather than inventing a parallel journal. |
| Non-item contact | crates/horizon-client/src/game.rs:12640 extinguishing uses cauldron::extinguished, not the item dispatcher. Entity contact, precipitation, dripstone and fluid capability transfers are not item interactions and must not invoke this registrar. |
Paths abbreviated as game/ above are under crates/horizon-client/src/. The vanilla caller query identifies AbstractCauldronBlock.useItemOn as the item dispatch moment. Future implementation must test each authority route above and both remote prediction paths.
PR #2835 owns fluid content/capacity, planned game/blocks/machines/cauldron_contents.rs, and native fluid capability integration. That file is absent at this base. Reuse its BlockKey/FluidKey and resolved resource snapshot when integrated; an interaction entry neither registers fluid capacity nor invents a second fluid table. Powder snow need not become a fluid. A fill recipe must respect the registered level schema and content capacity. PR #2697 owns activity/dispatch restructuring, following vocabulary/loading moves. Declare types in vocabulary and generate facade/transport through the owning generator after that work; do not restore old private point registries. Keep future adapters in small dedicated modules: game.rs, native_hooks/shared.rs and modding/api_values.rs must not grow beyond the fork's file-size gate.
Proposed typed registration
One registration moment, provisionally register_cauldron_interaction, corresponding to RegisterCauldronInteractionEvent. Public 26.3 verification remains required: adopt its exact names (Dispatcher and Interaction if verified), side and full contract before shipping, without aliases. No 26.3 counterpart is claimed verified here. Use this moment with typed dispatcher and entry phases if public 26.3 verification requires them. Register by (BlockKey, ItemKey) through a CauldronInteractions registrar with a Host. A custom block opts into a dispatcher explicitly; vanilla blocks come prebound. Several blocks may deliberately share a dispatcher, but binding and conflicts must resolve before publication. A convenience register-to-all expands over the complete prepared dispatcher set, including later mod declarations, never over the live mod list at click time.
Use exact typed items for the initial mod surface. Retain vanilla's tag branch in the same dispatcher; do not expose arbitrary mod tag priority until its counterpart contract is verified. Resolve tags against the authoritative prepared tag snapshot on both sides. Exact entries shadowed by the dye tag stay shadowed, even when washing passes.
Refuse duplicate entries (including attempts to replace vanilla entries) with CauldronRegistrationError::Duplicate, identifying both owners. No override flag, priority integer or last-wins behavior. Dependency order and stable declaration order determine diagnostics, not behavior: a conflicting preparation fails publication rather than choosing a winner. Unknown keys refuse admission as UnknownItem, UnknownBlock or UnknownDispatcher; unknown ordinary held items still pass without a new runtime error. These names are proposed API variants. Unsupported native/wasm callback or codec capability refuses registration as UnsupportedCapability, never silently omitting one target.
Build the entire resolved snapshot at resource preparation and reload, validate, then publish atomically with the existing resource/lifecycle holder. Failed reload retains the prior complete snapshot. Capture that holder in a decision; reload or world closure invalidates the decision through existing identity, without adding a cauldron-specific generation counter. Retain old callback code until its in-flight scopes finish. No retained native pointers or wasm callback indices may survive their owner. Client/server synchronize the existing resource generation and require a matching handler build before prediction.
Handler and prediction contract
Proposed API-owned Wire records: CauldronUse (dimension, target position and state, actor handle, hand, complete immutable held stack, underwater/creative flags and resource identity), CauldronDecision, CauldronPlan, CauldronResult and typed refusal/error enums. Use existing ItemKey, BlockKey, state, stack, lifecycle and handle representations; never serialize internal Action, Rust closures, pointers or borrowed Game state. Every field and result must support the same generated Wire boundary for native and wasm. A callable registration references the runtime's owned callback identity, not a function pointer in a packet.
Split decide, fire, apply. A handler receives a callback-scoped read Host with typed HostSide and returns Pass, Consume, or Success plus a bounded plan. Server fire receives a ServerHost with the game's open transaction for additional supported writes. Client prediction receives only ClientHost reads; it cannot mutate inventories, draw authoritative random, send messages, spawn drops or publish effects. No recursive dispatch from decide; refuse same-action reentry as Reentrant, on both targets. Server callbacks are invoked once per attempt, with nested transaction commits merging into the action. Pass discards all staged writes; Consume may stop item use with an empty plan (vanilla underwater behavior).
The plan describes one target block replacement, an optional exchange of one held item for one returned stack, and optional one sound, one fluid game event and up to two statistics (custom cauldron stat and ITEM_USED, preserving vanilla transfer capacity). Lower-fill BLOCK_CHANGE remains a distinct effect. Washing can edit the held components without an exchange. Helpers implement existing filled-result semantics instead of making mods reconstruct creative and overflow rules. More extensive world changes use existing journal-supported Host operations; unsupported operations refuse the action before any escape. No irreversible I/O during callbacks.
Prediction uses the same decision rules and selected entry but computes only result and visual proposal. Do not call the authoritative handler on the client. Missing matching client resources suppress custom prediction and await the server; they do not guess Pass and launch a second bucket use. Server revalidates target, dimension, actor identity, complete stack/components, relevant reads and resource holder before applying. Changed preconditions produce Stale, discard the entire action, and send normal authoritative correction; never recompute a different interaction inside that attempt.
Track speculative visual effects with the existing use/prediction acknowledgement identity. Confirmation reconciles the proposed block and suppresses duplicate local sound; refusal restores the authoritative block. Inventory, statistics, drops and game events are server-only. One server mutation is committed regardless of whether a client predicted. Preserve Success/Consume/TRY_WITH_EMPTY_HAND semantics and swing behavior across both hands; prediction must not cause a second UseItem after a consuming UseOn.
Atomicity, trust and cost
Open an action child transaction before the server handler, under any enclosing Host transaction. Journal the acting player's full inventory/hand/components and supported attachment writes, stage the block through journal_set_block, and queue sounds, statistics, game events and leftover drops. Validate the whole plan before mutation and again after callback writes. Nested success merges into its parent; only successful root commit publishes effects, block logs and outbound changes. Any refusal, stale read, panic, guest trap or outer abort restores the original inventory and block and discards every effect. Do not execute the callback again at root commit.
The existing journal already buffers cauldron blocks and effects inside a transaction, but the immediate route is not proof that arbitrary mod callbacks are atomic. Before supporting another write domain, prove its undo and publication behavior or return UnsupportedCapability. In lifted passes, defer entity/attachment retirement and journal Remove emission to root commit using the existing lifecycle holder; rollback preserves identity and attachments. Never assign a second generation to restored objects. Refuse saves while an action savepoint is open, including save-all invoked by a handler; extend crates/horizon-client/src/game/world/autosave.rs:6 admit_entity_save (currently a passes_settled check) and audit every save entry so tentative inventory or blocks cannot reach disk. This is a required implementation guarantee, not a claim that every such gate already exists.
Treat decoded plans as untrusted. Validate keys, state property values, current holder, actor scope, hand and stack components before use. Consume count is 0 or 1 and cannot exceed the held count; exchange count obeys the returned item's real stack limit. State must belong to a registered dispatcher and valid property schema; water/snow levels remain 1..3 and lava remains unlayered. Do not impose a smaller capacity than vanilla. Sounds/events/statistics resolve typed keys; reject unknown owned keys, non-finite volume/pitch/positions and out-of-range values rather than clamping malicious data. Proposed sound bounds are volume 0..=1 and pitch 0..=2, preserving these vanilla interactions. Use existing codec/NBT limits for stacks; cap plan at one exchange, block, sound and fluid event, and up to two statistics, with a separate lower-fill event. Finite host-write/nesting budgets must refuse atomically, never truncate valid vanilla work.
Contain native unwinding and wasm traps at callback dispatch, abort the complete action, and apply the runtime's reporting/quarantine policy. A native process abort cannot be recovered. A failing callback must leave the next built-in interaction usable.
This docs-only change has zero runtime cost. Future unused custom dispatch is one indexed (block, item) lookup after the existing constant dye-tag membership test, with no all-mod scan, allocation or Host construction for a miss. Resolve keys, tag membership and register-to-all at preparation. A custom hit pays for one selected handler and bounded plan plus actual journaled writes. Both prediction paths share that indexed dispatcher. Measure miss allocations and callback count before claiming the hot-path requirement complete.
Acceptance matrix for implementation
Vanilla isUnderWater checks the fluid above against FluidTags.WATER; the current game.rs:12350 and remote prediction use an exact water block check. This known discrepancy needs a waterlogged-above oracle case during integration; it is not silently treated as established parity. The immediate adapter also exposes one custom stat while vanilla transfers award ITEM_USED as well: preserve both through the plan and audit the surrounding item-use statistic owner to avoid double awarding.
These are required tests with literal expectations, not checks run by this note. Run on native and wasm and through integrated, LAN, dedicated and fake-player authority routes where applicable.
| Case | Expected values |
|---|---|
| Survival: empty minecraft:cauldron at (0,64,0), test:syrup_bottle count 2, inventory has space | Exactly one selected handler; hand count 1; test:syrup_cauldron level=1; one minecraft:glass_bottle inserted; exactly one authoritative minecraft:item.bottle.empty sound and one minecraft:fluid_place event; no drop. Fixture registers the filled block's integer level 1..3. |
| Same, then transaction refusal or enclosing abort | Empty original block; syrup count 2; no returned glass bottle, drop, stat, committed sound/event or block publication. A committed child cannot escape outer abort. |
| Same with full inventory / last bottle | Full inventory: one glass bottle drop only on root success. Last bottle: hand becomes one glass bottle. Abort restores the initial hand and publishes zero drops. Creative: original hand count remains 2, helper deduplicates returned bottle. |
| Prediction then confirmation | One server handler/application, count 1 and one authoritative sound/event; local sound heard once after reconciliation. Native and wasm return the same Success. Refusal corrects to empty/count 2. |
| Water potion, water cauldron level=1 | level=2, one potion exchanged for glass bottle with existing container rules, one bottle-empty sound and FLUID_PLACE. Full level=3 or non-water potion: Pass, no changes. |
| Dye-tag item also has exact item entry | With DYED_COLOR: only washing, dye removed and water 2→1. Without DYED_COLOR: Pass, exact handler count 0. No change to tag priority after reload. |
| Vanilla buckets and washing collections | All four maps and every color: water/snow extraction only at 3, lava always; underwater lava/snow Consume with unchanged stack/block; unpatterned banner Pass; patterned banner removes last pattern; dyed shulker becomes uncolored preserving contents. |
| Duplicate (minecraft:water_cauldron,test:syrup_bottle), unknown ItemKey, unsupported Wire capability | Duplicate / UnknownItem / UnsupportedCapability respectively; no partial snapshot publication, identical native/wasm diagnostic category. |
| Reload/world close between decision and apply | Stale refusal, no changes/effects, no retargeted handle or callback to unloaded code; failed preparation leaves old table intact. |
| Reentry, panic/trap, invalid count/state/NaN, journal budget exceeded | Whole action refuses, zero partial effects; subsequent vanilla water bottle still raises level 1→2. |
| Save requested inside action, including lifted pass | Refused; zero tentative state persisted, original holder identity survives abort. |
| No custom entry | Existing vanilla result/inventory/event order unchanged; zero mod callbacks and zero custom-dispatch allocations. |
Proposed stages and open questions
File no issues from this note. First obtain exact public 26.3 event/side semantics and approve this contract with the #2835 and #2697 owners. Second add API-owned vocabulary/Wire records, registration validation and one prepared native/wasm snapshot. Third integrate the shared dispatcher, both prediction paths and mandatory action transaction without growing oversized files. Fourth add real test-mod fixtures, adversarial rollback/reload tests and vanilla oracle coverage for every map/tag branch; then measure the miss path. None of these stages is implemented here.
- Counterpart scope: does public NeoForge 26.3 require arbitrary tag registrations, override, or different event phases/sides? Recommendation: obtain its public contract before freezing names; retain exact-item admission and vanilla tag precedence until then. Do not infer semantics from its implementation.
- Budgets: which shared limits cover additional Host writes and nesting without refusing legitimate vanilla interactions? Recommendation: use existing runtime/codec budgets where sufficient and pin the largest vanilla washing/container fixture before approving numeric journal limits.
- Prediction authority: are read-only custom decisions on both sides acceptable when hidden server state affects eligibility? Recommendation: let the server correct predictions; offer explicit server-only prediction suppression using the same selected entry, never an alternate item-use route. Confirm the missing-client-resource handshake behavior before code.
Design: villager professions and trades
Provisional proposal for #1328 and parent #1043, one workstream. Root approval of the typed public API and save/network gates is required before code. This page implements nothing and does not complete either issue.
Answer and references
Register professions at mod load through one load-owned typed registry. A resolved profession joins workstation/POI selection, level-to-trade-set keys, texture and hat selection, saved resource identity and its network projection. Trade JSON alone cannot register a profession. Use #2339's general resolved-table contract; do not introduce a second generation counter or an independent global registry.
Merged prerequisites #1333, #1360 and #1398 provide vanilla metadata, unresolved names and the frozen vanilla table. Parent prerequisites #1341, #1348, #1352, #1374 and #1379 cover POI, namespaced rendering and unresolved trading routes. #2635 adds secondary work-block tags, not custom profession registration. #2838 overlaps profession sounds/rendering; #2697 overlaps villager hook vocabulary. Coordinate those implementations rather than copying their declarations here.
Vanilla 26.3 reference: inspect VillagerProfession using
python3 tools/rea_vanilla.py class net.minecraft.world.entity.npc.villager.VillagerProfession
and method for its constructor, bootstrap, static initializer, accessors and
all five register overloads (select with --overload-index). Its record holds
name, held/acquirable POI predicates, requested items, secondary blocks, optional
work sound and an integer-level-to-TradeSet-key map. getTrades looks up that map.
The complete bootstrap declares:
| Profession | Held/acquirable POI | Trade levels | Requested items / secondary blocks |
|---|---|---|---|
| none | PoiType.NONE / ALL_ACQUIRABLE_JOBS | none | empty / empty |
| armorer | ARMORER / ARMORER | 1–5 | empty / empty |
| butcher | BUTCHER / BUTCHER | 1–5 | empty / empty |
| cartographer | CARTOGRAPHER / CARTOGRAPHER | 1–5 | empty / empty |
| cleric | CLERIC / CLERIC | 1–5 | empty / empty |
| farmer | FARMER / FARMER | 1–5 | wheat, wheat seeds, beetroot seeds, bone meal / farmland |
| fisherman | FISHERMAN / FISHERMAN | 1–5 | empty / empty |
| fletcher | FLETCHER / FLETCHER | 1–5 | empty / empty |
| leatherworker | LEATHERWORKER / LEATHERWORKER | 1–5 | empty / empty |
| librarian | LIBRARIAN / LIBRARIAN | 1–5 | empty / empty |
| mason | MASON / MASON | 1–5 | empty / empty |
| nitwit | PoiType.NONE / PoiType.NONE | none | empty / empty |
| shepherd | SHEPHERD / SHEPHERD | 1–5 | empty / empty |
| toolsmith | TOOLSMITH / TOOLSMITH | 1–5 | empty / empty |
| weaponsmith | WEAPONSMITH / WEAPONSMITH | 1–5 | empty / empty |
Each employed profession uses its corresponding work sound and profession-specific trade-set constants for all five levels; none and nitwit have null sounds. Textures and hats are separate rendering resources, not fields in this record. REA returned no body for the static initializer and synthetic lambda methods, even with raw evidence inspection. Their exact predicate/tag initialization is unverified; the table records the predicates passed by the complete bootstrap, not independently verified membership of ALL_ACQUIRABLE_JOBS.
NeoForge's public 26.1 migration primer
documents the move to separate villager_trade and trade_set datapack registries
and a profession's level map. The 26.3 primer
describes changed trade provider types. Neither establishes the current event
contract. The attempted public 26.3 profession documentation at
https://docs.neoforged.net/docs/26.3/misc/villager/ was inaccessible.
VillagerTradesEvent is the issue's named counterpart; its 26.3 existence,
side, mutation ordering, limits and full callback contract remain unverified.
Require public 26.3 documentation/Javadoc before adopting an event contract.
No NeoForge source or patches are a reference for this proposal.
Current owners and routes
Anchors below were checked against this worktree; they describe current owners, not implemented custom registration. Paths are repository-relative.
| Route | Current owner and required change |
|---|---|
| Vanilla seed and identity | crates/horizon-client/src/mobs/villager.rs:32, :113, :124: immutable vanilla-only table and unresolved state. Replace context-free access with the owning load's resolved table; retain names. |
| POI classification | crates/horizon-world/src/poi.rs:12: vanilla enum. Custom workstation states need typed POI resolution, tickets and classification before admission; extending a vanilla POI alone does not create a custom profession. |
| Job validation/acquisition | crates/horizon-client/src/mobs/villager_brain.rs:895, :898, :2131, :2169: held/acquirable filters and assignment routes use the same resolved reverse index. |
| Secondary sites | crates/horizon-client/src/mobs/villager_secondary.rs:4: profession-indexed tags and farmer defaults. Preserve #2635's world tag snapshot and sensor cadence; resolve custom membership once. |
| Profession change and auxiliary metadata | crates/horizon-client/src/mobs/villager.rs:471, :481, :563: offer reset, work sound and requested items must read resolved metadata. Do not grant farmer-specific farming AI merely by declaring requested items. |
| Trade loading/selection | crates/horizon-client/src/merchant.rs:335, :364, :376, :469: Trades owns data and overlays separately; replace minecraft-prefixed set construction with explicit level keys. |
| Merchant interaction | crates/horizon-client/src/game/items/trading.rs:220, :266, :348, :361, :431: creation, opening, refresh, direct opening and completion need resolved identity or unresolved refusal. |
| Mod merchant handles | crates/horizon-client/src/game/modding/native_merchant_refs.rs:69, :100, :217: preserve unresolved guards and identity reads; resolve typed writes through the same registry. |
| Save/load and zombie villagers | crates/horizon-client/src/entity_nbt.rs:1118, :1125, :3982, :4111: both species preserve the saved profession name and existing offers. |
| Render layers and hats | crates/horizon-client/src/mobs/render.rs:215, :952, :2259; crates/horizon-client/src/mobs/villager_hats.rs:18, :51: resolve actual texture resources and cached metadata, including zombie layers. |
| Client receive | crates/horizon-client/src/game/network/remote_mob_data.rs:471: currently strips minecraft namespace and ignores custom names. Replace with snapshot-scoped key resolution. |
| Server metadata | crates/horizon-client/src/game/network/lan/mod.rs:1020: both villager species currently coerce unresolved professions to index 0. Send the qualified identity through the peer mapping instead. |
| Protocol and tracking | crates/horizon-protocol/src/entity_data.rs:437 encodes VillagerData integers; crates/horizon-client/src/lan/entity.rs:408 pairs entities and :474 sends changes. Both require the peer's activated registry mapping before metadata. |
| World preparation and reload | crates/horizon-client/src/game.rs:11722; crates/horizon-client/src/game/administration/functions.rs:260, :1319: initial load and reload routes must prepare dependent tables together. Keep registry logic in sibling modules; game.rs must not grow. |
| Shared infrastructure | crates/horizon-mod-runtime/src/registry_keys.rs:6, :18: requests/names are lookup machinery, not a new lifecycle holder. crates/horizon-world/src/native_load_context.rs:81 carries load inputs, not mutable world authority. |
Proposed public contract
Proposed names, pending root approval: ProfessionKey, PoiKey, TradeSetKey
and ProfessionDefinition, owned by the public mod API. A definition contains
its namespaced key and translation component; workstation block-state references;
held and acquirable POI key sets; an explicit map for levels 1–5; requested item
keys; secondary block/tag references; optional work sound; and villager/zombie
texture resource identifiers with hat policy. Empty trade maps are legal. A
missing level means no new offers at that level, never a fabricated vanilla key.
Trade-set identity is independent of profession identity: test:engineer may
select test:engineer at level 1 without suffix or namespace rewriting.
Collect dependency-ordered declarations during load, before world admission,
with a load-scoped registrar and shared Host services. World mutation is
unavailable at this phase. No per-tick registration callback is proposed.
Generate native and wasm entry points and API-owned Wire records together;
never expose Rust pointers, closures, serde JSON values or guest addresses as
public transport. Refuse UnsupportedProfessionCapability for arbitrary POI
predicates, executable trade factories or unsupported render policies rather
than accepting inert fields. Do not add a speculative event beside #2697's hook.
Resolve references against the complete candidate graph, including blocks, POIs,
items, tags, sounds and trade sets. Build POI-to-profession and texture indexes
before publication. Recommend refusing overlapping acquirable POIs between
custom declarations until an explicit selection policy is approved. Held and
acquirable sets may differ. Duplicate profession keys refuse deterministically
with DuplicateProfession, key and both owners, independent of dispatch order.
Recommend no replacement of vanilla profession declarations in stage one;
ReservedProfession refuses it. Normal pack priority for trade resources remains
owned by the existing loader, not a competing profession override mechanism.
The current world epoch belongs to NativeLifecycle in
crates/horizon-client/src/game/modding/native_lifecycle.rs:65 and its epoch
accessor at line 83. Use that existing load/world lifecycle identity and #2339's table holder for all
resolved keys. Session/epoch validation and stale-key refusal follow that owner;
no profession-local generation. Publish profession, POI, trade and client
projection tables atomically. Failed resolution publishes zero entries and
retains the old active set on reload. Registration is fixed for the mod load;
reload re-resolves data and tags without rerunning mod startup. Runtime mod
unload requires closing its worlds. In lifted passes, queue retirement and journal
emission until root commit; rollback emits neither. Refuse saves during an open
action savepoint; never persist a candidate registry or provisional offer reset.
Saves, network and resources
Persist fully qualified names in VillagerData, plus existing serialized offers,
experience and restock state. On restart resolve names against the new owning
load. Missing mods retain Unresolved(name) byte-for-byte, disable job assignment,
profession reset and trade mutation, and preserve offers for later restoration.
Do not map an unresolved name through u8 wrapping, farmer or none. Render a neutral
base layer without discarding identity. Restoring the mod resolves the same name;
a different registry order must not change profession or trade-set identity.
Server authority applies to integrated, LAN and dedicated play equally. Negotiate
custom registry support before entity pairing through the #813/#2882
negotiation layer. Until that layer lands, refuse a client without the required
mod at admission with MissingRequiredProfessionMod(mod_id), naming the missing
mod and reason; do not send custom metadata or silently substitute vanilla. API-owned Wire projections carry
qualified profession/POI/trade keys, translation data, texture identifiers and hat
policy under the existing lifecycle identity. Reuse the common registry snapshot
transport: peers validate a complete mapping and acknowledge activation before
receiving its numeric references. VillagerData integers are scoped to that mapping,
never stable save identities or process-local slots. Invalid/out-of-range ids
refuse the packet/session; never leave an old farmer value silently in place.
An unsupported peer is refused at admission when custom professions are present;
vanilla-only sessions retain their existing protocol. Exact configuration and
snapshot packet integration is a protocol review gate, not implemented here.
The server sends resource identifiers, not arbitrary paths or texture bytes.
For test:textures/entity/villager/engineer.png, resolve that exact resource and
its metadata; current convention-only hat scanning must be extended to declared
textures. Resolve the zombie texture separately. Cache None/Partial/Full hat
metadata with the effective pack resources; default missing metadata to None and
preserve vanilla type/profession head-visibility composition. Client resource
reload refreshes this cache without replacing server profession identity. Missing
optional textures use the missing-resource visual with a diagnostic, never a
vanilla profession substitution. Required client content is checked at admission.
Bounds, failure and cost
Treat all mod declarations and network projections as untrusted. Proposed Horizon limits (not NeoForge limits): 65,536 total professions including all 15 vanilla entries; identifier length unresolved pending owning vanilla codec evidence; 1 MiB per declaration; 64 MiB aggregate candidate definitions; nesting depth 64; 4,096 references per set; at most five level mappings with unique levels 1–5. Reject traversal, malformed identifiers, missing required references, cycles in trade-set references and unsupported tags/providers before publication. Reuse bounded trade decoding; counts, costs, XP and provider results must be finite and fit their engine target ranges, including item stack limits. Never truncate an oversized input. These budgets preserve vanilla's profession and five-level capacity; the implementation must check the complete vanilla trade dataset fits before fixing trade budgets. Identifier admission is gated on evidence of the owning vanilla codec limit or an approved bounded-transfer route preserving its capacity. A 256-byte cap is not approved; dataset fit alone does not establish vanilla capacity.
Native unwind or wasm trap aborts candidate preparation with owner/key context; apply the runtime's failing-mod policy. Process-aborting native panics cannot be recovered. No callback executes while partially installing live tables. Future trade callbacks need a separate approved boundary and budget; they are refused by this declaration contract.
Resolve names and reverse indexes at load. Hot job, trade and render reads use validated indexed keys, with O(1) unused-path checks and no scans over mods, per-villager string construction or registration dispatch. Empty custom registries add no listeners or custom synchronization payloads. Existing secondary sensor spatial work keeps its cadence and skips empty membership. Hat application must use cached resource keys rather than its current formatting lookup.
Acceptance matrix
These are required future tests, on native and wasm, not results of this docs task.
| Input/route | Expected result |
|---|---|
| Declare test:engineer; workstation test:assembler; level-1 set test:engineer; texture test:textures/entity/villager/engineer.png | Exactly 1 resolved profession; all four relationships preserved on both targets. |
| Save test:engineer and restart with its mod | Same qualified name, level map and saved offers; no reroll solely from load. |
| Restart without the mod, save again, then restore it | Name retained exactly, 0 vanilla numeric assignments, 0 trade mutations while unresolved; restored identity resolves correctly. |
| Two owners declare test:engineer | DuplicateProfession names both owners; 0 partially published generations. |
| Guest joins or receives changed metadata with reversed registry order | Same test:engineer identity and texture; 0 substitutions of minecraft:farmer. |
| Vanilla minecraft:farmer, no custom declarations | Same composter selection and five trade-level keys; 0 custom callbacks/payloads. |
| Mod attempts minecraft:farmer declaration | ReservedProfession; vanilla unchanged. |
| Missing POI/set, duplicate level, cycle, 65,537 entries, depth 65, trap | Candidate refused; old table identity and trades unchanged. |
| Engineer texture hat Full; type hat None | Type-layer head hidden; exact declared texture used for profession layer. |
| Unsupported peer with custom content | Refused before pairing; 0 undecodable entity metadata. |
| Reload through each authoritative route above; failed candidate | One common atomic publication on success; 0 on failure, no mixed POI/trade tables. |
| Rollback within lifted pass; save during open savepoint | 0 retirement/journal effects before root commit; save refused. |
Proposed stages
- Root approves keys, declaration/replacement policy, budgets and save/network contract after #2339 alignment and public NeoForge contract verification.
- Implement generated native/wasm registrar and typed POI resolution together; validate duplicates, references and unsupported capabilities before publication. This server half may land as its own change, but main never accepts a profession registration that no client can show: until the client half in stage 4 lands, registration stays refused with a named reason, or both halves land in the same rollup.
- Route all owners above through the load-owned table, including merchant handles, zombie saves and resource caches; preserve unresolved behavior.
- Implement negotiated snapshot activation and all metadata send/receive routes.
- Run the matrix with vanilla parity, adversarial inputs and unused-path cost measurements. No registrar code is authorized by this provisional page.
Open questions
- Exact public registration shape and counterpart? Recommend declarative startup registration; verify 26.3 VillagerTradesEvent before choosing any event name, side or semantics. Root approves the public API before code.
- Custom POI sharing or vanilla replacement? Recommend refusing both initially; permit differing held/acquirable sets but require unambiguous acquisition.
- Network admission and activation? Recommend required custom-content capability and shared snapshot acknowledgment before pairing; protocol owners must approve packet placement and transfer/reconfiguration behavior.
- Final resource/trade budgets? Recommend the limits above after complete vanilla dataset measurement, with existing decoder limits retained wherever stricter without reducing vanilla capacity. No NeoForge limit is claimed verified.
Block entity neighbour changes
A block's BlockPart::on_neighbor_change receives the changed block entity's
position in neighbor. On authoritative servers, the comparator invalidation
walk visits the four horizontal neighbours, skipping unloaded adjacent chunks.
Vanilla comparator processing at each cell runs before its part is notified.
This is separate from neighbor_changed, the vanilla block neighbour callback.
A block one cell farther through a redstone conductor receives this notice only
when its weak_changes part sets receive to true (initially false). This query
belongs to the farther block. Reading that cell never loads a chunk.
These correspond to NeoForge's IBlockExtension.onNeighborChange and
getWeakChanges. Using the comparator walk and ordering vanilla before parts
are fork decisions. With no parts, vanilla's comparator rules are unchanged.
Block experience and explosions
Attach a BlockPart to the blocks that own the behavior.
exp_drop(level, event) answers Some(points) to replace the vanilla experience
sample, before enchantments. None leaves the answer to another attached part
and then vanilla. The query receives the dimension, state, position, optional
block entity, breaker entity handle and tool. The level host reads without writing;
level.block_entity_data(entity) reads the supplied entity's saved data without
identity metadata and refuses stale handles. Answers are clamped to 0 through
32,767. Player breaks supply the part's answer to the global break_block
event's exp_to_drop, after Silk Touch suppresses the starting amount (except
for spawners). An explicit global override is applied as given. block_drops sees the resulting amount afterwards.
Level and mob breaks use the shared resource-drop path. A canceled player break
or a break that does not harvest produces no award.
on_block_exploded(host, event) runs before the engine removes each destroyed
block. The server host permits writes. event.block_entity is a live handle,
and entity.data(host) can read it while the block remains. The event borrows
the same ExplosionStart description as the global start event: dimension,
center, radius, fire, interaction, damage type, source and owner.
The engine still writes air after the callback, including when the callback
replaced the block. Vanilla's separate wasExploded behavior follows removal.
Air, spared blocks, canceled explosions and explosions that keep blocks produce
no callback. Explosions retain their existing zero initial XP behavior.
Both methods route through prepared per-block subscriptions. Unsubscribed blocks build no callback context. Callback panics follow the existing native hook unwinding behavior; the engine does not apply a returned XP answer after a panic.
Client action pipeline design
The window owns input, targeting, animation and prediction. The logical server owns combat and world changes. A single-player window uses the same client path as a window connected to a remote server. Client action hooks serve mods such as auto-tool, hold-attack and mining overlays without changing the server's gameplay events.
Where an action starts
Game::remote_mouseupdates key state and queues presses. Keyboard bindings and controller actions should reach the same queue and held state. Raw input events can consume a physical input before it becomes a mapping. Action hooks run later, after the game has decided that a mapping may act.Game::remote_handle_inputsends any changed held slot, stops a dig when gameplay is unavailable and delegates toInteractionInput::tick. This is the boundary between physical inputs and gameplay. The input crate owns the sequence and should keep no dependency on mods.InteractionInput::ticksnapshots the crosshair once, drains attack, use and pick presses in order, then tries the held use and attack continuations. While an item is being used, it drains the press queues and releases the item when the use key goes up. Preserve this order and the same target for every attempt in a tick.
Input and prediction contracts
Extend interaction_key_mapping_triggered at its existing call sites. The event should
carry whether the attempt is from a queued press or a held mapping, alongside the
existing key, hand, cancellation and swing controls. A press attempt and a held attempt
can occur in the same tick. The reason travels from InteractionInput through the
game's InteractionWorld adapter. Deriving it from the current key state would confuse
a queued press with a held continuation. Listeners use the existing ClientHost
crosshair query while the tick's target is pinned.
| Attempt | Dispatch point | Cancellation |
|---|---|---|
| Attack press | The current attack-key event in remote_start_attack, after eligibility checks | Skip the attack and preserve the event's swing policy. Spectator actions keep their existing separate route. |
| Held attack | The current attack-key event in remote_continue_attack, when the game has a non-air block to mine | Skip this tick's continuation. An allowed attack press can still start a dig earlier in the tick. |
| Use press or repeat | The existing main- and off-hand use events, with the same reason propagated through both hands | Keep the current hand and fallback rules, including cancelling the entire use at the main-hand event. |
| Pick press | The existing pick event, before the target packet | Preserve the existing packet veto. |
client_mining_predict runs at the distinct prediction boundary inside
remote_mining, after target, restriction and delay checks. It describes the block
position, face, whether the accepted attempt starts or advances a dig, and the
proposed progress. Its typed snapshot and client host are read-only. It runs after
the interaction mapping event, so tool selection can settle before progress is
computed. The server still independently validates
the held item, position, reach and break progression.
A mining veto skips prediction, crack updates and the new action packet. For a continuation it leaves the existing dig intact so a later allowed attempt can resume it. A veto of a new target leaves the previous target untouched until the normal target-change or release path stops it. Ordinary release, focus loss, opening a screen and server correction must still clean up a running dig. These cleanup paths do not take a cancellable hook. Preserve the current retained-target behavior when a survival dig resumes after a miss.
Feature boundary
Auto-tool can distinguish a fresh attack from a mining continuation, inspect the current target and react before progress is calculated. Held use is distinguishable from a queued click. A mod can refuse predicted mining for selected blocks and still let the existing stop path clear the crack and send the abort. Holding attack on an entity keeps vanilla's attack cadence. Any API for requesting new actions, retrying attacks against entities, teleport correction or changing the server's validation belongs to a separate contract.
Verification
- Extend the input crate's behavioral tests to distinguish press from repeat and prove unchanged target lifetime, click ordering and release while using an item.
- At the client packet boundary check allowed and vetoed attack, main/off-hand use, active dig continuation and creative instant break. Compare emitted packets, sequence allocation, prediction, swing and crack state. Check a tool switch before a start and between continuation ticks.
- Exercise release, screen opening, focus loss, target loss and a server correction while an action listener refuses progress. Verify at most one abort for a running dig and no surviving crack after cleanup.
- Run the same fixture through the single-player window's integrated server connection and a dedicated connection. The mod hook should fire only on the window side.
- Compare the no-subscriber path against the pinned vanilla input/mining harness and avoid constructing snapshots or scanning subscribers on unsubscribed held ticks.
Create and Mekanism capabilities
Clean-room capability audit of main 5c2583c030 (2026-11-08); no foreign mod or NeoForge source consulted. Status applies to the stated engine operation, not a completed port. Tests below were inspected, not run here. Public wiki requirements are unversioned unless noted; exact release applicability remains unknown. Create's official CurseForge description (listing 6.0.10 for Minecraft 1.21.1) establishes animated automation, but no detailed interaction contract. The Create manual and Fandom Deployer page were inaccessible; detailed moving-structure requirements come from #794, and fake-player interactions are the audit scope requested by #806, not a claimed Create contract.
Source abbreviations: items, fluids (v9 upgrade note), energy (V10 upgrade note), chemicals, sides are official Mekanism user wiki pages. They motivate operations, not engine implementation details.
Repository references below are verified paths and lines. Prefix API = crates/horizon-mod-vocab/src/; Game = crates/horizon-client/src/game/.
| Engine capability (public reason) | Status | Evidence and covered route |
|---|---|---|
| Insert items through a sided block provider and roll back an aborted write (items). | Works | API parts/capabilities.rs:392 tests North forwarding to a chest, Up refusing slots, commit and abort. Game blocks/machines/native_items.rs:52 resolves the holder, :65 selects face slots, :73 handles root-commit notification. |
| Move fluid between block holders, retrying a partial acceptance atomically (fluids). | Works | API resource.rs:601 implements nested transaction extraction/insertion and retries the accepted amount after rollback. Game blocks/machines/native_fluids.rs:554 tests partial cauldron transfer through LevelRef::capability(FLUIDS); :365 and :425 implement tank writes. This test uses no side; side-specific fluid transfer is not established here. |
| Query a block entity from a particular side (sides). | Works | API parts/capabilities.rs:392 independently asserts different Up/North results and forwards to the neighbour. This is a FakeHost provider test, supplemented by the native container face route above. |
| Obtain typed energy storage and transfer it transactionally (energy). | Works | API energy_capability.rs:12 defines EnergyHandler, :27 the ENERGY key, :72 move_energy. Energy is an amount, not a resource identity. Game blocks/machines/native_energy.rs:294 tests sides, limits, movement and abort; :62 writes journalled block-entity NBT. |
| Transfer a mod-defined chemical between two providers with rollback (chemicals). | Works | Game runtime/transactions.rs:1486 tests two mods sharing CHEMICALS: 600 becomes 350/250 after moving 250; abort restores prior amounts. API parts/capabilities.rs:245 is the tank Part implementation and :261 writes through block-entity saved data. |
| Refuse chemical identity conversion in a tank (chemicals). | Works | API parts/capabilities.rs:284 rejects insertion of a different held identity; :303 extracts only a matching identity. Covered route is the example mod handler used by the chemical test, not an engine guarantee for arbitrary mod handlers. |
| Encode/decode tank contents in machine NBT (fluids). | Works | Game blocks/machines/native_fluids.rs:1097 tests NBT round-trip and missing entries; :1128 tests empty-fluid serialization. This establishes the machine-data codec, not disk restart. |
| Preserve committed tank contents across a complete world save/reload (fluids; persistence is an audit requirement). | Works | Game blocks/machines/tank_reopen_tests.rs fills a mod block's game-owned tanks (native_block_entity_fluids) with a mod fluid and lava in a committed transaction (an aborted insert is rolled back). It saves with Game::save_game, drops the game, opens the folder in a new game and streams the chunk back from its region file: both tanks keep their identity and amount. Route: an integrated server in one process with a native mod. LAN, a dedicated server process and wasm mods are not covered by this test. |
| Expose a capability on an entity holder (items/energy/fluids). | Works | API entity_capability.rs:95–123 checks the live level, selects entity-type providers, invokes them with the entity/context and falls through to the game answer. Game blocks/machines/native_energy.rs:151 tests entity energy/fluid movement and rollback. |
| Expose a capability on an item holder (items/energy). | Works | API item_place.rs:561–591 resolves the current stack, selects providers by item type, invokes the Part with the item place and returns the typed answer. This traced provider path establishes lookup, not every provider's mutation/persistence behaviour. |
| Reject a retained block-entity handle after replacement (machine data). | Works | Game modding/native_block_entity_refs.rs:627 tests replacement refusal and identity restoration on rollback. This does not establish lifetime behaviour for every item/entity capability wrapper. |
| Perform a fake-player action and restore its setup after success/refusal (#806 scope). | Works | Game modding/native_server.rs:549 implements scoped actions; :3739 tests returned items and persistent-state restoration; :3832 tests viewpoint/secondary-use setup and refusal restoration; :3980 tests hook visibility. API receiver is the player service's player_perform_action_scoped at host/players.rs:406. |
| Assemble blocks and block entities into one movable, rotating owner (#794). | Gap | API entity Parts and handles provide no structure assembly/carrier operation; searches for StructureRef, assemble_structure, moving_structure in API and Game find no implementation. Entity capability lookup above only queries existing holders; it does not transfer ownership of blocks/tickers. See the gap brief below. |
Verified gap
| Expected versus observed | Reproducible missing call and natural receiver | Issue/design |
|---|---|---|
| Assemble two blocks, retaining block-entity contents and one ticking owner; translate/rotate, save and synchronize that owner. Main exposes no assembly operation or structure handle; ordinary entity capability providers cannot perform that ownership transfer. | Place two machine blocks at (0,64,0)/(1,64,0). A mod needs level.assemble_structure(host, cells, pivot) returning a proposed StructureRef, then structure.set_transform(host, transform). Neither entry point exists. Proposed API belongs on LevelRef for assembly and the returned structure handle for motion; atomic ownership transfer must be engine-owned. | Existing #794; design PR #2310, intended page concepts/moving-structures.md once landed. These are proposed names pending that design, not current API. |
Route limits
Works rows establish exactly the listed operation. Game tests use an in-process host (some set dedicated options), not a running network acceptance scene. LAN, integrated-server scheduling, actual dedicated networking and wasm execution were not verified for these rows. Item/entity lookup implementations were traced through provider dispatch; no claim is made that every provider journals correctly. API wire_resources.rs:18–30 explicitly refuses erased capabilities across link groups; this audit does not establish typed provider interoperability across all native/wasm groups. Chemical identity policy remains the provider's responsibility.
No hook count or complete-port claim follows from these results. Board searches used gh issue list -R arismoko/horizon --state all --search with energy, fake player, and moving structures; the verified carrier gap maps to #794. No issues were filed and no additional gap is inferred from untested routes.
Death sources at the loot hook
LootDrops::damage_source carries the death's damage type and causing/direct entity IDs.
Non-death drops have no source. Environmental deaths retain their type, with 0 for
absent or unknown IDs. player is remembered kill credit, not necessarily the attacker.
tool is the breaking or using tool (LootContextParams.TOOL), and is always None
for entity deaths. To read the attacker's main hand, resolve damage_source.causing
with host.entity(id), then host.living(entity), and call
living.hand_item(host, Hand::Main). Handle resolution can fail for an absent attacker.
This is a live read, including all stack components: changes during living_death
and by earlier loot_drops listeners are visible to later readers. No hand snapshot
is carried in the context. Native and wasm use the same Host operations.
Projectile direct IDs are currently 0; melee direct IDs equal the causing ID. The shooter's live hand is not the projectile's recorded firing weapon. Reading that recorded weapon through the damage source is separate work.
Design: a mod's biomes inside the Overworld and Nether
Provisional answer to scope 2 of #2554: how a mod's biomes can generate among vanilla's in the Overworld and the Nether. This is a proposal, not an implemented API. Scope 1, a mod's own dimension with its own climate points (BiomeSourceKind::Points), is separate and does not need this note.
Decision from the issue
Ari's answer on #2554 sets two starting points, and this note keeps both:
- Regions with weights. Each mod lays out its biomes in its own region. A hidden extra parameter decides which region owns an area, and vanilla's layout is one region among them. Ordering between mods is settled by the regions, not by competing for the same climate points.
- The mix is fixed when the world is created and saved with the world. The promise is: terrain in an existing world changes only by a recorded, per-world choice.
The same answer says NeoForge has no API for this: its biome modifiers change biomes that already generate and cannot add a biome to a dimension's layout, and the region mechanism is a third-party library. So there is no NeoForge contract to mirror. That statement comes from the issue; this note did not verify it, and it did not read the third-party library's source. Behaviour of that library is therefore not claimed anywhere below.
Horizon today
| Fact | Where |
|---|---|
| A level's biome source is one of fixed, checkerboard, multi-noise by preset, the End, or (scope 1) a mod's own points. | BiomeSourceKind in crates/horizon-mod-vocab/src/world/dimension.rs |
The game writes the level stem's biome_source JSON; the generator decodes it when a world is prepared. | biome_source in game/modding/native_dimensions.rs; worldgen/biome_source/codec.rs |
A multi-noise source is a list of (ParameterPoint, biome) searched for the nearest point to the six climate values and a zero offset. | worldgen/climate/index.rs (ClimateIndex, ClimateSearch) |
| The Overworld's and the Nether's lists are named presets, not data a mod can extend. | BiomeSourceKind::MultiNoise, biome_preset |
| A chunk stores its biomes, so a chunk that already exists keeps them whatever the source says later. | not checked in code for this note; vanilla stores biomes per chunk section, treat as unverified here |
Not verified for this note: how vanilla's MultiNoiseBiomeSource presets are built from parameter lists, whether terrain depends on a chunk's biomes or only on the climate router, and how the existing preset lists are cached.
Proposal
Region. A region is (owner mod, region id, integer weight, points), where points are the BiomePoints of scope 1. Vanilla's own list is the region minecraft:vanilla with a fixed default weight. The declaration is a load-time registrar call, Registrar::declare_biome_region(path, weight, points), namespaced to the declaring mod and refused for a malformed or duplicate id, a weight of 0, or invalid points, as declare_attribute is.
Order. The region list is sorted by owner mod id, then region id, the rule the attribute catalog uses (#2191). The same mods give the same order on every side, so it can be saved and compared.
Choosing a region. A low-frequency noise of the world seed, sampled per biome cell, picks a region by cumulative weight. The nearest-point search then runs inside that region's own index only. A region never competes with another region's points, which is what makes ordering between mods unnecessary. The noise's exact form, frequency and its random-stream position are open (see questions) and must not disturb any vanilla stream: a world with no regions declared must generate exactly as today.
Fixed at creation. When a world is created the ordered region list (owner mod, region id, weight and full climate points, including biome ids) is written into the world's data, not hashes. A point's numeric payload is 52 bytes plus its biome id and region metadata; there is no per-region total because this proposal sets no point bound. The saved layout wins on load:
- the same mods installed: use the saved points even if a mod later changes its declaration;
- a mod added: ignored for that world unless the player opts in;
- a saved region's mod absent: tell the player which mod and region are missing and require an explicit per-world choice: do not open, or open with a recorded substitution. With substitution, vanilla's layout takes that region's share from then on. Save that decision once with the world, rather than silently substituting on each load. A dedicated server requires the decision in a setting and refuses to start otherwise, explaining the missing mod and region and the required choice.
Opt-in for an existing world. A recorded per-world choice, off by default, adopts the installed layout, including new regions or changed points. Without it the full saved points remain authoritative. Already generated chunks are never touched, including after a missing-mod substitution. The border between old and new chunks can show a biome seam. Whether terrain shape can also seam depends on whether terrain reads the biome (open, see questions).
API surface. One registrar method, one BiomeRegion record with Wire, and a read of the saved list (level.biome_regions(host)) for mods that want to know what the world was created with. No new hook.
Acceptance matrix (proposed, not reported passing)
| Fixture | Expected |
|---|---|
| No region declared | Biome output of the vanilla presets is identical to today for a fixed seed, at several coordinates. |
| Two mods, weights 1 and 3, vanilla default | Over a large sample, region ownership follows the weights within a stated tolerance; the same seed gives the same map twice. |
| Same mods installed in a different order | Same sorted list, same map. |
| World created with mod A, mod A removed | Name the missing mod and region; require do-not-open or a recorded per-world vanilla substitution. Dedicated servers refuse without the setting. Existing chunks are never touched. |
| Mod A changes its climate points | Full saved points win until a recorded per-world opt-in adopts the new layout; existing chunks are never touched. |
| Mod B added to an existing world, no opt-in | No change in any generated or new chunk. |
| Opt-in on an existing world | New chunks may use B's region; existing chunks unchanged; the list saved afterwards includes B. |
| A region with invalid points or a duplicate id | Refused at registration, naming the mod; no region installed. |
Open questions
- What noise picks the region, and how is its seed offset kept from moving any vanilla random stream? Recommendation: a dedicated noise instance seeded from the world seed and a fixed salt, with the same sampling grid as the biome source, verified against the vanilla presets as an oracle.
- Does terrain shape read the biome, or only the climate router? Recommendation: check before the opt-in border is promised to be seam-free.
- Should weights be integers with a fixed default for vanilla, or relative to it? Recommendation: integers, vanilla at a fixed default, written down in the API docs.
- Nether, Overworld and the End: should the End be included? Recommendation: Overworld and Nether first, as the issue asks.
- Where is the saved list stored (level data or a separate file), and how does it round-trip the full saved points and recorded per-world decisions? Recommendation: decide with the world-save owners before any code.
Implementation boundaries in Horizon
The generator resolves the preset's biome source from the dimension's generator settings in
generator.rs.
BiomeSourceCatalog
decodes a named multi-noise preset separately from an inline list. Keep both routes intact:
the region selector belongs around the resolved Overworld or Nether preset, and only when a
saved layout enables it. Inline points for custom dimensions keep their existing behavior.
Biome selection can change generated blocks. The generator passes biome samples into surface
material rules and uses carver_biome to select a carver list at each origin. An opt-in can
therefore produce material and carving seams in newly generated chunks. Do not promise a
biome-color-only transition.
For the save owners to review: store a versioned, Horizon-namespaced world record separate from
data/minecraft/world_gen_settings.dat. Key the full layout and recorded substitutions by
dimension. Preserve the exact region order, weights and points on read. Reject malformed data
and missing biome IDs before constructing a generator. An absent record in an existing world
means vanilla generation until the player opts in. Persist an accepted change before
generating chunks with the new layout. Pin the selector algorithm and its constants to the
record version, since changing either would move regions in existing worlds.
For the generator owners to review: use a dedicated seed stream and sample in quart coordinates.
Choose the weighted region before querying its climate index. Check the total weight for
overflow, and retain a substituted region's weight and slot so unaffected regions keep their
boundaries. Use distinct fixtures for the Overworld and Nether, including negative coordinates
and chunk boundaries. Compare the unmodified preset against the Java oracle and compare the
region-enabled path with no saved regions against that same baseline. The existing
test_biome_source_parity.py covers fixed,
checkerboard and End sources; it does not establish seeded multi-noise preset parity.
Stages
- Settle the open questions above, with oracle fixtures for the presets.
- The registrar,
BiomeRegionand validation, with unit tests and a wire round trip (no generator change). - Region selection inside the biome source, behind "no region declared means identical output".
- Saved list, load comparison and the per-world opt-in.
- Docs, an example mod and the acceptance matrix.
Design: Attachment query joins
Provisional proposal for #2497, extending #1108's attachment query contract. Docs only; approval precedes implementation. The existing single-type API remains the same operation, extended through typed query specifications, without aliases or compatibility paths. Follow Part instance state for lifecycle identity and root commit rules.
Evidence and route owners
Vanilla 26.3 BlockEntity.saveCustomOnly(HolderLookup.Provider), inspected using tools/rea_vanilla.py from /workspace/horizon, delegates object-owned saving through ValueOutput. It establishes no attachment join contract. Vanilla has no counterpart to this mod query facility. The issue explicitly names no NeoForge counterpart: this proposal is judged on its merits. NeoForge 26.3 query behavior is unverified; no public documentation establishing such a query was inspected and no NeoForge source or patches were used. Public documentation would be required before adopting any claimed NeoForge contract.
Verified current owners (repository-relative paths):
| Route | Owner and consequence |
|---|---|
| Single-type capture and write validation | crates/horizon-client/src/game/modding/attachment_index/mod.rs:31 and :43: select self.dim, lock its index and call snapshot_named; writes validate only that current index today. Cross-dimension routing must replace that limitation for explicitly scoped rows. |
| Capture callee | crates/horizon-mod-runtime/src/attachments/index.rs:241: hash lookup and Arc clone, no holder scan; :257 validates residency with an owner hash lookup. |
| Borrowed traversal | crates/horizon-mod-runtime/src/attachments/query.rs:96: read each captured entry through read_validated; decoding can allocate and fail. No default insertion. |
| Decode callee | crates/horizon-mod-runtime/src/attachments.rs:497: query read/cache path, distinct from canonical writes. Decode cost depends on codec and bytes, not constant time. |
| Residency installation and retirement | crates/horizon-mod-runtime/src/attachments/resident.rs:26 and :42; index :193 registers, :344 unregisters, visiting that owner's keys. |
| Saved entities, dimensions, players | crates/horizon-client/src/game/modding/attachment_index/mod.rs:55, :80, and players.rs:7: bind at lifecycle boundaries, never during query reads. |
| Journaling and nesting | crates/horizon-client/src/game/runtime/transactions.rs:191, :237, :302: open, merge/rollback and capture attachment maps. This is a holder-map journal, not a need for simultaneous mutable entry references. |
| Native/wasm boundary | crates/horizon-mod-vocab/src/entity/attachment_query.rs:63 and :80: existing manual API-owned Wire token/batch conversion; evidence of the current route only, not a serialization pattern to extend. New crossing types must derive Wire. |
These owners establish existing mechanics, not implementation of the contracts below. Before code, audit every #1606 residency producer, lifted-pass exit, world close, transfer, chunk unload and save gate against the same publication boundary; the exact #1606 owner integration remains a prerequisite.
Typed API and atomic writes
Choose tuples over a builder for access declarations, with options for scope/filter. Illustrative proposed syntax:
host.query((read(&MANA), read(&TEMPERATURE)), options)?;
host.query_mut((write(&MANA), read(&TEMPERATURE)), options)?;
host.query_mut((write(&MANA), write(&TEMPERATURE)), options)?;
row.apply(host, (Set(8), Keep))?;
row.apply(host, (Set(8), Remove))?;
Support arities 1 through 8 through generated tuple implementations. Reads require Send + Sync + 'static; writable slots additionally require Clone, as today. Read slots expose borrowed captured values and cannot accept replacement operations. No escaping mutable references. Reject duplicate keys, including two read slots, before capture with proposed DuplicateQueryKey; reject empty/oversized tuples as InvalidQueryArity. Declaration/type incompatibility remains an error, never a skipped holder. A row is a holder having every requested key, not a Cartesian product; joins never cross holder identities or dimensions.
apply opens one nested savepoint, validates every captured holder/residency and expected binding revision, pre-encodes all replacements, then invokes ordinary journaled writes for each writable slot. Merge only after every operation succeeds; otherwise abort the entire row. Recommend proposed StaleBinding when a queried binding has changed since capture, including remove/reinsert. This adds optimistic conflict checking for joined writes; existing single-key semantics need not change. Snapshot values never change after apply. A read slot's revision is also checked because the decision may depend on it. Same-holder/key recursive edits refuse; no borrow or index lock crosses a codec or callback.
Two writes to one holder reuse its first journaled map image. Different dimensions must journal under scoped dimension identity, never the currently selected relative Holder alone. Nested commit merges into the caller's action; root abort restores both types, membership, dirty flags and queued sync. There is no transaction on batch drop and no automatic transaction around a whole loop: use host.transaction for all rows. Root commit alone publishes journal emissions and retirement during lifted passes; save refuses while an action savepoint is open. Rollback must not invoke mod code.
Scope and residency
Options select Current, Dimensions([DimensionRef, ...]), or AllLoaded. Current is the default. Explicit references validate world and loaded status, deduplicate, and refuse unloaded dimensions without loading them. AllLoaded captures the loaded-dimension registry at acquisition, including parked dimensions, never persisted unloaded worlds. A newly loaded dimension appears only in a fresh batch.
Capture all selected per-dimension versions on the owning server thread in one non-reentrant capture phase, without callbacks, ticking or dimension swaps between captures. This is one logical snapshot, not sequential observations across ticks. Each row carries its captured owning dimension for validation/routing; entity identity still uses the existing lifecycle epoch/id and contains no dimension. Reuse existing holder lifecycles and residency tokens; add no second generation.
Parked residents remain visible. Lifted residents remain visible exactly once through #1606's authoritative owner table and stable attachment binding, despite temporary absence from a raw list. Never enumerate raw lists to reconstruct a query. Queued removal remains a residency view until the committed removal boundary. Transfer publishes departure/arrival atomically at root commit; a fresh capture cannot see both copies. Old captured values remain readable; writes after residency changes refuse rather than retargeting a successor. If a lifted owner cannot safely accept a write, refuse explicitly before mutation; never treat temporary list absence as permission to recreate it.
Cross-dimension apply resolves the captured dimension through the host's world service under one transaction; it must not silently change the host's selected dimension or run gameplay callbacks under another dimension. Client reads cover only loaded authorized replicas; writes require ServerHost. World close invalidates live authority, without changing already captured immutable values.
Holder filters
Use API-owned AttachmentHolderKind::{Entity, Player, BlockEntity, Chunk, Level} plus optional API registry handles for entity type or block-entity type. Players are a distinct holder variant; Entity does not implicitly include Player. Validate filter/type compatibility and registry handles before capture. Unknown or foreign registry handles refuse, not an empty accidental match.
Store immutable kind and resolved API type id with residency metadata. Check the candidate row's metadata before probing the other types or decoding values. This is O(1) per candidate, not zero total work and not a separate kind index. No engine enums cross the API. Replacement refreshes metadata through the existing lifecycle owner; type-preserving mutations retain it. Plain blocks and item stacks remain outside supported attachment holder kinds.
Cost, transport and untrusted input
For k keys in D selected dimensions, let n_d be the smallest bucket size in dimension d and R the matched rows. Capture costs expected O(Dk); matching costs expected O(sum(n_dk)), plus O(R*k) value access and actual codec work. With bounded k this is O(sum n_d), not O(all holders). Absent keys make that dimension empty immediately. Explicit dimension validation costs O(D); AllLoaded necessarily enumerates D loaded dimensions only when called.
The current dense bucket snapshots lack immutable per-key membership probes. Add a hash map from existing scoped holder/residency identity to row slot in each versioned bucket; capture its version together with dense rows. Do not build it by scanning the larger bucket at query acquisition. Probe those captured maps, never live mutable maps, to preserve snapshot joins. Retained versions require O(bucket size) metadata/map copy on first subsequent mutation; ordinary hash operations are expected, not worst-case, O(1). Memory is O(total indexed memberships) plus retained versions and O(Dk) batch descriptors. Decode/encode and transport add costs proportional to visited payload bytes. Native warm traversal allocates no per-row metadata; first decode may allocate. Wasm whole-batch serialization materializes matched rows and costs O(Rk + bytes); do not claim O(1) acquisition for wasm.
No query or attachment/lifecycle mutation means zero extra index work per tick: at most an O(1) enabled-path branch, zero allocations, codecs, holder scans or all-mod scans. Unbound holders create no query memberships. Lifecycle work touches only that holder's attachment keys. Subscription/type keys resolve at registration. Benchmark against #1811's 10,000-holder baseline, reporting capture, traversal, codec, transport, retained-version copy time and allocations separately; no invented timings.
Native and wasm share API-owned Wire query specs, scopes, filters, row errors and scoped apply payloads through the existing generator. One whole-batch capture import; one whole-batch apply import for submitted updates, never per-row imports. Validate the complete apply payload before executing row savepoints; an explicit enclosing transaction supplies whole-batch atomicity. Every new public crossing type must carry an explicit #[derive(Wire)] (fully qualified as #[derive(horizon_mod_api_macros::Wire)] where needed). The generator builds only the host call tables; serialization belongs to the derive, never copied or extended manual token/batch Wire implementations. The derive entry point is crates/horizon-mod-api-macros/src/lib.rs:6; existing derived public types include crates/horizon-mod-vocab/src/state_predicate.rs:6. Require the shared Wire capability for every boundary type and refuse registration with proposed UnsupportedQueryWireCapability, naming type and capability. No hand-written codec or guest pointer crosses the ABI.
Treat keys, scopes, tokens, lengths and replacements as untrusted. Proposed limits: 8 keys, 256 explicitly named dimensions, the existing shared NBT limits (64 MiB encoded bytes and depth 512), and existing shared Wire message budgets; refuse oversize before mutation, never truncate or constrain vanilla storage capacity. AllLoaded over transport budget returns a capacity error, never partial silent results. Query reads preserve malformed saved bytes and report row errors without defaults. Native unwind or wasm trap aborts active savepoints and pending publications under runtime callback policy; process-aborting panics are unrecoverable. Disable/report the failing callback according to runtime policy. Tuple and explicit-dimension limits require approval before implementation; payload limits reuse the shared boundary budgets. The current batch conversion calls Limits::default() for both encoding and decoding (crates/horizon-mod-vocab/src/entity/attachment_query.rs:93 and :108); its defaults are defined at crates/horizon-core/src/nbt.rs:202 and :204. Wire nesting has its own representation-aware depth budget, 1040 (crates/horizon-mod-vocab/src/wire/mod.rs:73), not an NBT depth override.
Acceptance matrix
Expected future tests, not results from this documentation change:
| Fixture | Concrete expected result |
|---|---|
| A has mana 7/temp 20; B only mana 9; C only temp 30 | One joined row A=(7,20); zero defaults, dirty flags or packets |
| A write/read join | Set mana 8 succeeds; temp remains 20; old batch remains (7,20) |
| A write/write; second codec fails | A remains (7,20), zero dirty/sync publication |
| Set A=(8,21), nested commit, root abort | A=(7,20); zero outbound updates |
| Captured A; live temp becomes 22 | Apply refuses StaleBinding; mana remains 7, temp 22 |
| X contains A=(7,20), parked Y contains B=(9,30) | Current X yields 1 row; AllLoaded yields 2; sum mana=16 |
| Lift B out of Y list during pass | AllLoaded still yields B exactly once; no extra membership |
| Transfer B Y to X; root abort | B remains in Y once; zero transfer publication |
| Unload Y after capture | Old B reads (9,30); apply refuses StaleHolder; fresh AllLoaded has 1 row |
| Zombie, cow, player, chunk each have both keys | Entity/zombie yields 1; Entity yields 2; Player yields 1; Chunk yields 1 |
| Same coordinates in X and Y | Write X to 8 leaves Y at 9; foreign-world token refuses |
| Two keys with sizes 10,000 and 100, overlap 40 | 100 candidate visits, at most 100 other-key probes, 40 rows; never 10,000 candidates |
| 10,000 unbound holders, 100 idle ticks | Zero new memberships, codecs, allocations and index work |
| Duplicate key; 9 keys; 257 explicit dimensions | Refusal before capture; zero writes |
| Valid 65 KiB replacement within shared budgets | Succeeds; no join-specific 64 KiB refusal |
| Encoded NBT over 64 MiB or nesting beyond shared depth 512 | Refusal before mutation; A remains (7,20), zero payloads |
| Native and wasm fixture A/B/C above | Both return A=(7,20), identical errors; zero per-row imports |
| Missing Wire capability | Registration names failing type/capability before first query |
Proposed stages
File: none. First approve tuple access, atomic row apply, scope/filter and limits. Then audit #1606 lifted and parked ownership, retirement, cross-dimension journal keys and save refusal. Add snapshot-compatible probe maps and residency metadata; require explicit #[derive(Wire)] on every new public crossing type, with no copied or extended manual Wire implementations; extend generated native/wasm host call tables for capture/apply together. Finally implement the matrix and #1811 comparative benchmark before advertising support.
Open questions
- Resource budgets: reuse existing shared NBT and Wire budgets; approve only the tuple and explicit-dimension limits above; maintainer approval is required before code.
- #1606 integration: recommend stable bindings through lifted passes and root-only publication; name and verify every concrete owner and save-gate route in the implementation PR. The current route audit does not establish those end-to-end guarantees.
Design: baked models and ModelData
Provisional answer to #861: the engine owns immutable modified baked results for each resource generation; mods own bounded edit descriptions, never mutable renderer models. Evaluate ModelData on the client world-owning thread before freezing geometry selection. Workers read concrete results from section meshing's #1508 plan. This extends that plan's input boundary; its scheduling, halo, publication, geometry limits and rendering rules remain authoritative. No Rust API is implemented; every stage below has file none pending design approval.
Evidence and limits
Vanilla 26.3 was inspected only through tools/rea_vanilla.py from
/workspace/horizon: method net.minecraft.client.renderer.chunk.SectionCompiler compile, its callers query, and method net.minecraft.client.renderer.item.ItemModelResolver updateForTopItem.
SectionCompiler reads a render region, selects block models by state, passes a
position seed to tessellation, treats fluids and block entities separately, and
sorts translucent output. Its verified caller is CompileTask.doTask. Item
resolution clears output and appends layers for nonempty stacks with display
context, level, owner and seed. These observations do not establish a vanilla
ModelData API or a vanilla mod callback. The owner-thread preparation boundary
below is Horizon policy.
The public NeoForge model guide was reachable on 2026-11-09 but labelled 26.1. It describes client baking-result edits for block and item models and block-entity ModelData consumed during model selection. It is contextual evidence only. The requested 26.3 ModifyBakingResult Javadoc was inaccessible. Exact 26.3 event threading, mutability, reload ordering, ModelData invalidation and item applicability are unverified. In particular, no claimed NeoForge radius, size, thread-safety or item-data limit is verified. Require public 26.3 documentation of those contracts before adopting counterpart semantics or implementing the hook. No NeoForge source/patches or other project's source were consulted.
Ownership and registration
Propose one client modify_baking_result moment, with ClientHost, after baking
and before publishing caches on initial load and every resource reload. Client-lib
prepares the candidate; game dispatches subscribers in dependency order; validation
and publication apply in the same transaction, rechecking resource/mod generations.
Refuse stale preconditions without recomputing inside apply. Resource preparation
may be asynchronous, but dispatch and commit return to the client owner thread.
Keep standalone registration unchanged.
API-owned proposed representations: BakedModelKeyWire (resource generation,
resolved slot and block/item kind), BakedModelEditWire, BakedModelRecipeWire,
ModelDataSchemaWire, ModelDataValueWire, ModelEvaluationInputWire, and
ModelEvaluationResultWire. Reuse #1508's prepared model/material/quad Wire
values rather than introduce another geometry format. Recipes select bounded
prepared variants or validated replacement geometry, with explicit particle,
lighting, culling and item transform metadata. Wire is a mod boundary, not network
traffic. No renderer structs, pointers, closures or GPU objects cross it.
Resolve model targets and schema property ids once at registration. Properties have declared scalar/enum or bounded-vector types and defaults; no arbitrary serialized objects. Refuse unsupported capabilities at registration with owner and capability named: worker callbacks, live-world evaluation during meshing, unbounded neighbourhoods, arbitrary native model delegates, raw pipelines and undeclared data types. Apply the same refusal on native and wasm. Reject conflicting exclusive replacements with both owners named; composable edits run in dependency order. Unknown feature-owned target ids refuse registration; unrelated existing unknown-id no-ops retain their behavior.
Build generation B from B's original baked inputs, never from modified A. Publish block and item tables atomically. Shared immutable engine references lease A until old jobs and item draw snapshots release it; A cannot publish into B. Mods receive scoped keys, not ownership of engine allocations. Teardown follows #1508's job lease/drain ordering before unloading native code or wasm stores.
ModelData evaluation boundary and routes
Registered providers receive a read-only captured evaluation input plus
ClientHost at owner-thread preparation. Their result is a typed property value
set, not a callback stored in the model. World writes are forbidden during this
preparation moment. Block inputs use #1508's radius-one snapshot and explicit
Missing cells; model selection freezes deterministic seeds, data revision,
resource generation and prepared geometry keys into state recipes/sparse overrides.
The mesher receives those through the existing proposed SectionMeshPlanWire.
There is no worker hot-path dispatch, native trait-object invocation or wasm
call for this feature. Existing worker tint capabilities remain as documented in
section meshing; they do not authorize ModelData callbacks.
Cache by model key, typed input revisions and seed. Block/state, neighbour, block-entity data, light/biome dependencies and provider invalidations dirty the applicable plans; never poll arbitrary mod state per frame. Reload invalidates all old data keys. Providers explicitly request invalidation on changed source data. Missing data uses declared defaults. Stale data refuses publication using #1508's tokens. Items evaluate during owner-thread item preparation using captured stack components, display context, optional level/owner and seed; freeze output before drawing. A block item does not inherit world-position ModelData implicitly.
Verified current integration owners at base 0ef42f9e54 (paths relative to repo;
these are seams, not already implemented callbacks):
| Route | Owner | Proposed integration |
|---|---|---|
| Block library construction | crates/horizon-client-render/src/model_library.rs:64, :82 | Produce candidate block view before modified cache publication |
| Item library construction | crates/horizon-client-render/src/model_library.rs:120 | Produce item view in same generation |
| Standalone additions and block preparation | crates/horizon-client-render/src/models.rs:37, :142 | Preserve additions; edit baked results after preparation |
| Terrain model consumption | crates/horizon-client-render/src/mesher.rs:1840 | Read frozen model parts; no provider calls |
| Item bounds and draw submission | crates/horizon-client/src/item_render.rs:229, :267, :299 | Consume prepared item geometry including skinned submission |
Initial mesh, remesh, halo changes, unload, reload, cancellation and stale upload all use section meshing's route and token table, not a second invalidation system. GUI, held, dropped, frame and equipment item callers must converge before item submission; exact preparation caller coverage remains to be audited before code. Special block-entity drawing stays on R2; it may consume a prepared model but does not acquire a terrain callback. Dedicated servers neither bake nor evaluate.
Untrusted input and cost
Proposed ceilings: 64 properties per schema, 256 elements per vector, 16 KiB encoded and decoded ModelData per evaluation, 256 variants per recipe. Checked length arithmetic precedes allocation; count captured values against #1508's 32 MiB section and 128 MiB in-flight budgets. Geometry obeys that page's quad, coordinate, UV, colour and material bounds. Reject NaN/infinity, invalid tags, stale keys, duplicate properties and values outside declared scalar ranges before selection. No partial edits: refusal preserves the original candidate model.
Catch unwind-capable native panics at the adapter; wasm traps or fuel exhaustion have identical refusal. Disable the faulty registration for this generation, diagnose once and invalidate its cached selections; use original baked geometry and default data. Native abort cannot be caught and native callbacks cannot be safely forcibly preempted. Wasm gets finite fuel and the same output limits. Generated adapters share semantics and dependency ordering across both targets.
With no subscribers, choose the existing path once at resource/dispatch setup: zero ModelData allocations, provider calls, per-block checks or all-mod scans. With subscribers, dispatch only indexed providers during preparation; workers read arrays. Reload edits cost only at bake time. Performance numbers remain unmeasured.
Acceptance matrix
Future implementation tests, not results of this docs change:
| Input | Concrete expected value |
|---|---|
| Six-quad model changed to one quad, first load then reload | 1 quad on both; edit called once per generation, not twice cumulatively |
| Untouched six-quad model | 6 quads before and after reload |
| Boolean property false/true selects variants 10/11 | Default false gives 10; update true gives 11 after invalidation |
| Same item in GUI and ground with declared context variants 20/21 | GUI 20; ground 21; no implicit block-position data |
| A job finishes after B reload | 0 A uploads; B model remains selected |
| Data vector length 257 or payload 16385 bytes | Whole result refused; default false selects 10 |
| NaN, infinity, stale material key, panic or wasm trap | No partial edit; original cube has 6 quads |
| Identical native/wasm inputs and seed | Same property values, model 11 and quad/material sequence |
| Worker consumes 4096-cell plan | 0 ModelData callback invocations on worker |
| No registrations | 0 extension allocations and callbacks; baseline geometry identical |
| Registration requests live worker evaluation | Refused before any job is scheduled |
Proposed stages
- Public-contract audit and approval (file none): obtain exact 26.3 documentation, settle counterpart semantics and audit every item preparation caller.
- Shared boundary and generation ownership (file none): approve API-owned Wire types, registration refusals and native/wasm codecs together.
- Baking and owner-thread evaluation (file none): implement one transactional moment, reload leases and provider invalidation with focused fixtures.
- Frozen-plan and item integration (file none): wire #1508 and item preparation, then run the matrix and measure empty-path cost before enabling registration.
Open questions
- Exact NeoForge 26.3 contract? Recommend requiring public documentation before naming the final hook contract; do not infer restrictions from 26.1 or source.
- Arbitrary dynamic delegates versus finite variants? Recommend finite recipes and owner-prepared geometry; refuse unsupported registrations explicitly.
- Item ModelData parity? Recommend a distinct typed item evaluation input under the same preparation contract, conditional on documented counterpart coverage.
- Final R2/#1508 geometry identities? Recommend reuse of their approved keys and generation leases rather than a parallel model store.
- Data budgets and provider scheduling cost? Recommend the explicit ceilings above initially, with dense-scene measurements before raising them.
- Complete item caller convergence? Recommend an implementation gate: audit GUI, hands, dropped items, frames and equipment and test context-dependent selection on each before claiming full route coverage.
Design: chunk attachment disk hooks
Provisional proposal for #857; no implementation or adopted guarantee. This answers the worker Host versus server-thread snapshot question: retain the existing server-thread capture seams, but execute mod load, save and persistence codec callbacks on a chunk worker with a restricted Host. Do not pass Game to a worker or silently relocate the requested callbacks to the server thread.
Evidence and limits
Vanilla 26.3 was inspected from /workspace/horizon with python3 tools/rea_vanilla.py method net.minecraft.world.level.chunk.storage.SerializableChunkData copyOf and the same command for parse. copyOf rejects unserializable chunks, copies sections, light arrays and heightmaps, and collects block-entity tags and proto entities. parse constructs serialized chunk data and returns null when Status is absent. These establish separate capture and decoding boundaries, not a vanilla mod attachment hook or proof of worker scheduling. Vanilla callers, unload ordering and disk failure recovery remain unverified; audit them with the same oracle before code.
The issue names NeoForge 26.3 ChunkDataEvent.Load/Save. The public attachment documentation describes optional persistence serializers but is labelled 26.1. The 26.3 event Javadoc was inaccessible. Therefore NeoForge 26.3 thread, event ordering, cancellation, exception, size and data-loss limits are unverified. Require public 26.3 event and attachment lifecycle documentation before adopting a parity contract. No NeoForge source, patches or other project's source were consulted. The rules below are Horizon recommendations pending that evidence.
Verified current owners and route audit
Paths and lines refer to this checkout; these are inspected owners, not completed callback routes.
| Route | Current owner and required integration |
|---|---|
| Explicit full save | crates/horizon-client/src/game.rs:14861 save_level captures full copies, calls the seam at :14888 and submits save_later; gate before capture and enqueue one worker callback per copy. |
| Streaming unload | crates/horizon-client/src/game.rs:16089 handles proto snapshots; :16101 handles full copies from unloaded chunks before save_later. Hold recovery data until acknowledged persistence. |
| Late full generation | crates/horizon-client/src/game.rs:14871 and :16080 call native_late_full_chunk_snapshot before save_finished. Use the same worker envelope, not a second hook. |
| Closing world | crates/horizon-client/src/game/world/saving.rs:176 and :193 call proto/full seams. Drain callback and disk acknowledgements before destroying worker runtime. |
| Proto save, synchronous and asynchronous | crates/horizon-client-render/src/streaming.rs:2534 prepares saves; :2578 finish_save selects synchronous save_protos or submit_protos; :1095 executes Job::Save. Both must pass the same callback boundary, including the synchronous route. |
| Lower-level bypass | crates/horizon-client-render/src/streaming.rs:2519 save and :1570 save_finished can bypass Game dispatch. Centralize dispatch in the persistence job owner so these cannot omit it; avoid duplicate callbacks from Game seams. |
| Full/proto adapter | crates/horizon-client/src/game/modding/native_chunk_save.rs:9,18,27 are server-thread preparation seams; :24 and :47 are empty. Retain capture authority here, replace empty dispatch with job preparation. |
| Disk attachment encode/decode | crates/horizon-client/src/save.rs:1623 writes attachment NBT; :1885 restores it. Load callback belongs after this decode, before publishing the loaded holder; save callback precedes this encode. |
| Residency publication | crates/horizon-client-render/src/streaming.rs:1530 installs loaded block entities. Require successful attachment load completion before any corresponding live publication. |
Forced saves, periodic saves, dimension transitions and shutdown must be traced to these owners during implementation; this audit does not claim their entire caller graphs are verified. Background job failure currently requires an explicit result/acknowledgement audit rather than assuming Done::Saved means durable success. Remote clients must never run authoritative disk hooks. Missing chunks and newly generated chunks with no decoded disk record do not fire load; proto-to-full promotion does not fire a second load.
Proposed contract
Capture only committed attachment state on the server thread under the existing world visibility/save lease. An open action savepoint refuses saves through the #2211 save gate, including forced, unload and shutdown saves; do not serialize tentative state or take a second private transaction snapshot. Defer unload when the gate refuses. Reuse the existing lifecycle AttachmentHolder/ChunkRef and residency owner, scoped to world and dimension; do not introduce another chunk generation. A snapshot capability expires at callback return and never resolves back to the live world.
A job owns its isolated attachment store. Save dispatch runs after capture and before encoding. Listener writes affect that store only. Capture 7, listener writes 8, live world changes to 9: disk gets 8, live remains 9. Use immutable shared encoded values or deep owned copies; never share mutable typed values or hold a world lock across mod code. Dependency-ordered listeners run sequentially for a job. Serialize jobs for a chunk in capture order, reject stale completions after residency/world retirement, and never allow an older write to replace a newer successful save.
Load dispatch runs after bounded disk decoding and before publication. Its successful edits become the loaded attachment store; failure publishes no partially initialized holder. Full and proto disk loads follow the same rule. Unload notification follows successful save completion when persistence is required; unload itself does not delete saved attachments. Persistence does not imply network synchronization.
The worker Host implements shared Host facilities only where safe: immutable registry/settings snapshots, handle construction and a local transaction over this job's attachment store. It exposes no world mutation, scheduling into live state, live chunk lookup, Parts, packets or Game access. Ordinary ChunkRef live operations refuse in this context; snapshot attachment methods validate the job capability and existing holder identity. No mutable borrow escapes a callback. Recursive edits to the same key refuse, and callback failure aborts the whole job's tentative edits.
Native and wasm use the same catalog hooks, dependency order and generated adapter. Define API-owned Wire representations ChunkDiskContextWire (world scope, dimension, chunk position, full/proto kind and load/save phase), AttachmentDiskEntryWire (registered key, schema, bounded encoded value), and ChunkDiskResultWire (success or structured refusal). These are proposed names and fields, not existing types. Each declared typed value needs both its persistence codec and the shared Wire capability required by dispatch. Refuse registration before first callback if worker-safe execution, codec or Wire support is absent, naming the type and missing capability. No Rust Any, pointers or runtime handle serialization across wasm. A native module has the same validation obligations and must explicitly support concurrent worker invocation; unsupported modules refuse registration rather than gaining server-thread fallback.
Failure, trust and cost
Propose 64 KiB per encoded value, depth 64 and 1 MiB total mod attachment data per chunk, including unknown keys; validate lengths before allocation, keys/schema/scope before mutation, and finite numeric fields where the schema requires them. Refuse oversize data without truncation. These budgets apply only to attachment data, not vanilla chunk contents. Preserve bounded unknown keys opaquely; do not turn unknown unrelated ids into new errors. Corrupt or over-budget input must remain recoverable on disk rather than being overwritten by defaults.
On codec error, native unwind or wasm trap, abort the snapshot transaction, report the mod/key/phase and keep the prior disk record. Disable the failing callback according to runtime policy; never silently retry without it and claim a successful save. A process-aborting native panic is unrecoverable. Rollback uses engine-owned captured bytes and invokes no mod code. Disk failure retains a retryable snapshot and dirty/recovery ownership, reports failure to wait/flush/close, and prevents silent successful unload. Partial publication and clearing dirty state before acknowledgement are forbidden. Exact atomic-write and crash durability guarantees require an audit of the disk writer before adoption.
Resolve subscriptions and capabilities at registration. With no listener, one cached subscription check selects the existing persistence route: zero new per-chunk allocations, codec calls, worker messages or mod scans. Existing attachment persistence costs remain. Subscriber work is bounded by the declared entries and budgets; use bounded queues and backpressure rather than unbounded retained snapshots.
Acceptance matrix
Proposed tests; none are claimed executed for this design.
| Fixture | Concrete expected result |
|---|---|
| Capture 7, callback writes 8, live writes 9 | Reload 8; live 9; callback cannot read 9 |
| Load disk 8, callback increments | Published value 9; one load invocation |
| Full, proto, late, forced, unload, closing saves | Each captured record has exactly one save callback, before encode |
| Save revisions 10 then 11 complete out of order | Disk revision 11; revision 10 cannot overwrite it |
| Open action savepoint with tentative 9 | Save refused by shared gate; disk remains 7; zero callback invocations |
| Unload/reload | Saved 8 survives; old live handle refuses; fresh holder reads 8 |
| Codec error, unwind or trap after setting 8 | Disk remains 7; no partial load publication; structured failure |
| Disk write fails | Old disk 7 retained; snapshot 8 retained for recovery; unload/close reports failure |
| Native and wasm execute identical job | Both save 8; live lookup refuses; unsupported Wire registration names type/capability |
| Value 65537 bytes, depth 65 or total 1048577 bytes | Refusal; no truncation; prior record preserved |
| Zero subscribers, 100 chunk saves | Zero added allocations, callback calls and job messages |
Proposed implementation stages
File: none. These are future work packages, not Rust changes in this note.
- Obtain public NeoForge 26.3 evidence and approve worker capability, budgets and failure policy.
- Complete vanilla caller and Horizon route audits, including forced/periodic/close and disk acknowledgements; integrate the existing save gate and lifecycle owner.
- Specify and generate Wire declarations, registration refusals and native/wasm worker dispatch; centralize the callback boundary below all preparation routes.
- Implement isolated load/save stores, bounded queues, rollback and ordered persistence acknowledgement without early dirty clearing.
- Exercise the matrix through real disk routes on both targets and measure the unsubscribed path.
Open questions
- Thread contract: recommend worker dispatch and server capture as above; require public 26.3 evidence before adoption, rather than infer scheduling from event names.
- Lifecycle authority: recommend the existing world-visibility owner approve snapshot capability validation and unload recovery; no second generation counter.
- Failure handling: recommend fail closed with retained recovery data and explicit close failure. Audit writer atomicity and decide crash recovery before advertising loss prevention.
- Worker module safety: recommend explicit registration capability for both targets; settle runtime instance concurrency and wasm fuel limits before implementation.
- Budgets: recommend 64 KiB/value, depth 64, 1 MiB/chunk plus bounded queues; confirm against the shared Wire budgets and unknown-key preservation policy.
- Remaining routes: recommend treating the unverified forced/periodic/transition caller graph and vanilla unload sequence as mandatory implementation evidence, not inferred coverage.
Design: client extensions
Provisional answer for #895. This proposes ownership and registration, not an implemented API or a completed NeoForge parity audit. Implementation requires review first.
Reference and category discrepancy
The named question is the complete extension surface for items, blocks, fluids and entities, and its mapping to a typed client-extension registrar. The issue's actual ask and batch 20 triage instead name effects as the fourth registration category. Do not silently equate an entity renderer with a status-effect renderer. The proposed four counterpart builders are item, block, fluid and effect; entity capabilities resolve to existing entity rendering owners, outside that four-category registrar until public documentation establishes otherwise.
The public 26.3 registration Javadoc returned HTTP 503 during this investigation. Triage reports four registration categories and registration queries, but does not establish the complete method surface. NeoForge 26.3 method names, defaults, overloads, duplicate policy and limits therefore remain unverified. Require accessible public documentation and a method-by-method disposition before adopting the final counterpart contract. No NeoForge source, patches or other project's source are inputs. The following is a complete proposed capability disposition, not a claimed complete census of NeoForge methods. Unsupported methods must never become silent no-ops.
Vanilla has rendering consumers rather than Horizon's registrar. The vanilla 26.3 SectionCompiler.compile oracle query is recorded below; terrain compilation is the relevant block/fluid convergence boundary. Other vanilla consumer details remain unverified here; the linked merged designs retain their own evidence.
Surface and ownership mapping
Reuse Part instance state, baked models and ModelData, and section meshing. Their lifecycle, geometry, budgets and invalidation contracts remain authoritative; this registrar adds no second store, mesher, generation counter or draw bus.
| Category and proposed complete capability families | Owner and disposition |
|---|---|
| Item arm/use poses and first-person hand transforms | Existing get_arm_pose and item declarations; register into those owners, not a second callback at their moment. Humanoid extraction remains #1024. |
| Item GUI/held/ground/frame/equipment geometry, special rendering and model selection | Baked-model preparation and existing DrawContext submission; refuse capabilities requiring unapproved renderer interfaces. |
| Item equipment textures/models, fonts and presentation overrides | Typed resource references resolved at reload; refuse UnsupportedClientExtensionCapability until each consumer and public counterpart are audited. |
| Block hit/break particles and particle sprites | Existing block particle/Part paths; preserve vanilla fallback and existing particle ownership. |
| Block model data, terrain geometry, tint and special block-entity drawing | Baked models, section meshing and R2 respectively; reference their registrations rather than wrapping them with a duplicate event. |
| Fluid still/flow/overlay textures, tint, light and world appearance | Existing fluid appearance plus terrain fluid consumer; resolve resources before frozen worker input. Fluid world behaviour remains #793. |
| Fluid camera overlay, fog and other view effects | Existing view-effect extraction/draw owners; refuse until their typed consumer contract is approved. |
| Effect inventory/HUD visibility, icon and custom drawing | Separate effect-keyed presentation capability; ordinary HUD and inventory consumers must both be audited before support. Never key by EntityKey. |
| Entity renderer factory, layers, extraction, model/texture and visibility | Existing entity rendering, #896/#897 and general entity design; no entity builder masquerading as the effect counterpart. Unsupported general renderer requests explicitly refuse. |
Current route census
Verified file:line anchors at this worktree's base ff35cdee95 describe current owners, not proof that proposed dispatch already exists.
| Route | Current owner | Required integration |
|---|---|---|
| Item declaration and humanoid pose | crates/horizon-mod-vocab/src/content/content_declarations.rs:228; crates/horizon-mod-vocab/src/hooks/get_arm_pose.rs:23 | Reuse one resolved pose path for player and humanoid consumers. |
| Block particle sprite and destroy particles | crates/horizon-client/src/game/blocks/block_particles.rs:47, :83 | Resolve block extension through the existing particle owner; hit route still needs caller audit. |
| Fluid box drawing and appearance lookup | crates/horizon-client/src/game/modding/native_fluid_render.rs:15, :40 | Reuse appearance; this box route alone does not establish terrain/camera coverage. |
| Mob/player geometry and layers | crates/horizon-client/src/game/modding/native_mob_draw.rs:57, :74, :97, :119 | Preserve existing entity submission; non-mob families require #896/#897 audit. |
| Terrain workers, reload, cancellation and upload | Section meshing's verified route table | Same frozen plan, leases and stale-result checks; no live registrar callbacks on workers. |
| Item baking and GUI/hand/ground/frame/equipment submission | Baked-model design's route table | Complete its explicitly outstanding caller audit before claiming coverage. |
| Effect inventory/HUD; fluid camera and fog | Concrete complete owners not verified | Registration refuses these capabilities pending owner/caller audit; do not claim coverage. |
| Native/wasm setup and teardown | Proposed generated game adapter, no implementation yet | Same schemas, dependency order, refusals and lifecycle on both targets. |
Every route either uses the listed owner or is explicitly unsupported. Initial load, resource reload, world switch, unload, mod teardown and failed reload must be acceptance cases. Dedicated servers do not dispatch client registration.
Typed registration contract
Propose one register_client_extensions setup moment with ClientHost and a scoped ClientExtensionRegistrar. Typed item/block/fluid/effect keys select small builders; queries inspect staged registrations without invoking providers. Resolve keys once after declarations and before rendering. Dependency order is shared by native and wasm. A second provider for the same key/capability refuses with both owners named; distinct capabilities can compose. Extensions to another mod's declaration require an explicit dependency, not accidental load order.
Proposed API-owned Wire types: ClientExtensionRegistrationWire, ClientExtensionCapabilityWire, ClientExtensionTargetWire, ClientExtensionResourceRefWire and ClientExtensionRefusalWire. These carry explicit tags, bounded lengths and typed keys; no Rust trait object, guest pointer, engine model or GPU object crosses the boundary. Callback references resolve through the generated runtime adapter. Refusals include UnknownExtensionTarget, DuplicateClientExtension, UnsupportedClientExtensionCapability, MissingClientExtensionWire and StaleClientExtensionResource. Unknown-target refusal applies only to explicit registrar requests, never existing vanilla no-ops.
Stage an immutable indexed table; validate all requests before atomic publication. Reload prepares a replacement and keeps the old valid table on failure. Resource keys and callbacks lease existing resource/mod lifecycle holders; no second epoch. Stop new dispatch, drain leases, then unload code. Object state follows existing holders: stack components for items, existing scoped entity/block-entity handles where available, and no invented plain-block or fluid instance holder.
Callbacks use their existing owner Host and decide/fire/apply transaction. Recheck captured versions at apply and refuse stale decisions. In lifted passes, defer retirement and journal emission until root commit; nested abort exposes no partial registration or state. Saves refuse while an action savepoint is open, as specified by Part state. DrawContext is supplied only by the owning draw moment.
Untrusted input, bounds and cost
Validate native and wasm identically: enum tags, key ownership, resource existence, finite numeric values, length arithmetic and declared schemas before allocation or publication. Propose 4096 new registrations per mod and 64 KiB encoded/decoded registration records; these bound extension additions, never vanilla registry capacity. Geometry and state payloads retain their owners' bounds. Reject negative, NaN or infinite values where finite nonnegative values are required; capability specific ranges require public-contract review, not arbitrary clamps.
Catch native unwinds and wasm traps at the adapter, discard the whole tentative result, diagnose once and disable the faulty capability for the current lifecycle; use the unchanged built-in result. Native abort cannot be recovered. Wasm receives finite fuel; native callbacks cannot be forcibly preempted safely.
Resolve dense slots at setup and select the ordinary path when no capability is registered: O(1) dispatch selection, zero extension allocation/callbacks and no all-mod scans. Workers consume the existing frozen representations. Performance is proposed, not measured.
Acceptance matrix
Future implementation tests, not results of this note:
| Case | Concrete expected value |
|---|---|
| No registrations, built-in cube | 6 faces; 0 extension callbacks and allocations |
| Item pose override for A, unregistered B | A uses selected pose; B retains its baseline pose |
| Duplicate item A pose providers | 1 DuplicateClientExtension; 0 partial publications |
| Unknown explicit fluid target | 1 UnknownExtensionTarget; existing empty-fluid draw still emits 0 quads |
| Entity renderer requested through effect builder | Typed API rejects it; malformed Wire gives 1 refusal |
| Unsupported equipment font/HUD capability | 1 UnsupportedClientExtensionCapability before first draw |
| Record of 65537 bytes or registration 4097 | Whole offending registration batch refused; vanilla registry unchanged |
| NaN, infinity, negative required-nonnegative value, native panic or wasm trap | 0 partial results; original built-in output retained |
| Reload B fails; delayed A job completes after successful C | B publishes 0 tables; A publishes 0 stale uploads |
| Root abort after tentative holder edit | 0 journal emissions and retirements; prior value restored |
| Save during open action savepoint | 1 save refusal; 0 tentative bytes saved |
| Identical native/wasm registration inputs | Same accepted slots, refusal tags and rendered outputs |
| Approved effect icon in inventory and HUD | Exactly 1 owner callback per visible icon in each route |
Proposed stages
- Public 26.3 method census and category approval (file none): enumerate every interface method/default/overload and assign one owner or named refusal.
- Consumer audit (file none): complete item, particle, terrain/camera fluid, effect inventory/HUD and non-mob entity routes with verified caller anchors.
- Typed registrar and generated adapters (file none): approve Wire schemas, duplicate policy and lifecycle integration together for native and wasm.
- Owner integration (file none): implement only approved capabilities through existing Parts/rendering; run the matrix and measure unused-path cost.
Open questions
- Fourth category? Recommend effects for the documented counterpart and explicit separate entity ownership; obtain maintainer agreement on the task wording.
- Full 26.3 method surface and semantics? Recommend public documentation as a hard implementation gate; current family matrix cannot establish full parity.
- Unsupported families and incomplete caller coverage? Recommend named refusal until each owning design and route audit is approved, never silent acceptance.
- Registration ceilings? Recommend the proposed additive limits, measured against representative mods without reducing built-in capacity.
Design: custom recipe-book settings
Design for #1023. Storage, registration, player settings handles and negotiated synchronization are implemented. This is a Horizon design; vanilla 26.3 is the reference for the four existing books. No other project's implementation is a reference.
Player settings operations
PlayerRef::recipe_book_settings(host, &key) reads an authoritative
RecipeBookSettings { open, filtering } pair. Fresh registered books read
false, false; unavailable players and unregistered keys return None.
PlayerRef::set_recipe_book_settings(host, &key, settings) is a server-only
write. It leaves every other book untouched, synchronizes changed flags and
notifies the existing player_recipe_book_settings_change moment once.
That notification now carries RecipeBookIdentity: one of the four vanilla
identities or Custom(RecipeBookTypeKey), never a fabricated vanilla ordinal.
The values explicitly derive Wire and share the generated native/wasm boundary.
A client mod reads PlayerRef::synced_recipe_book_settings and submits intent
with PlayerRef::request_recipe_book_settings through a ClientHost. Engine
menu panels use the same view through Game::custom_book_settings and
Game::request_custom_book_settings. Until a negotiated snapshot arrives, or
while a revision gap needs a replacement snapshot, reads return None and
the panel must stay disabled. Requesting never changes saved flags. Disconnecting
with pending intent restores the server's last accepted pair on the next join.
Registration and ownership
Add an API-owned RecipeBookTypeKey, a validated namespaced resource id, and
register declarations in the existing mod registration phase. Registration
returns a typed key; it does not extend BookType or its ordinals. Require an
explicit namespace owned by the registering mod, lowercase resource-id syntax,
a nonempty namespace and path, and at most 256 UTF-8 bytes. Reject minecraft:.
Duplicate keys fail registration with the key and both declaring mod identities;
invalid ids report the mod, supplied id and violated rule. Never silently select
a winner. Freeze the registry when registration finishes, before loading player
state or negotiating connections. Late registration returns an explicit frozen
registry error. Resource reload does not reopen it.
Declarations are shared content on server and client. Native and wasm use the
same declaration, validation, limits and generated boundary contract. A client
must register a key to negotiate it; a server-only declaration can retain state
but cannot make an unmodified client display a book. Reads belong on a player's
recipe-book settings handle with &dyn Host; authoritative writes take
&mut dyn ServerHost. A client UI requests a change through its client settings
handle; it cannot directly mutate server state. Do not add player-id helpers to
Host. Registration uses its existing registration receiver.
Book identities are separate from RecipeBookCategory in
crates/horizon-mod-vocab/src/item/recipe_book.rs. Categories group recipes and provide
icons; a book owns two flags. A screen associates categories with a registered
book explicitly through Screen.recipe_book. Neither category registration nor
recipe-type registration implicitly registers a book. Recipe unlocking and
placement remain separate from displaying the declared recipe layouts.
Registration API
In Hooks::register, call r.recipe_book_type("demo:cooking_pot") on the
existing Registrar. It returns Result<RecipeBookTypeKey, String>; failures
include the declaring mod, supplied ID and rule and refuse installation even
if ignored. Keep the returned key (mod_api::item::recipe_book::RecipeBookTypeKey)
for screen bindings. as_str() returns its stable identity and is_registered()
checks membership in this process's installed registry. No public unchecked
constructor exists. Native and wasm share the same registrar method through
the generated Wire boundary; the key explicitly derives Wire.
This registration does not create a category, settings handle, UI or network payload. Successful installation freezes the registry; resource reload does not reopen it. The settings and synchronization operations described here are implemented on the typed player handle.
Storage, persistence, copy and replace
Keep Settings.open and Settings.filtering as [bool; 4]. Add a custom map
from validated key to a value { open: bool, filtering: bool } alongside them in
crates/horizon-client/src/recipe_book.rs. Missing keys read as (false,false).
There is no fifth vanilla index. Use one shared map implementation for local,
host and guest players, persistence and payload application.
Persist the map inside recipeBook under horizon:book_settings, a compound
whose keys are book ids and whose values contain byte booleans open and
filtering. Write both flags, including zero, and sort keys for stable output.
Missing extension means an empty map. Preserve well-formed entries even when
the owning mod is absent; registration is required for live access and sync,
not for retention. Parse the extension independently of vanilla recipe-list
validation so an invalid recipe list does not erase retained custom flags.
Bound the extension to 1024 entries, 256 bytes per key and 512 KiB encoded data. Check lengths before allocating or iterating nested values. Accept only a compound of compounds with both flags byte 0 or 1; skip malformed individual entries with a diagnostic. Reject a duplicate key or an over-limit extension as a whole, leaving the custom map empty and vanilla settings intact. The NBT reader must enforce the byte/depth budget before materializing this subtree. Registration also caps custom types at 1024, reporting overflow at registration. Do not evict absent-mod entries to make room: a write exceeding the cap fails without changing state. This bounds retention as well as network input.
Copy is an independent value copy of all four vanilla pairs and every custom
entry, including absent-mod entries. Mutating either player afterward cannot
change the other. Replace assigns the complete source settings value, clearing
destination-only entries; it is never map merge. Existing recipe and highlight
sets retain their existing whole-Settings copy semantics. Import without the
recipe_book section preserves the complete destination value. Import with the
section replaces it, including clearing custom entries when the extension is
missing. Host/guest swaps must move the complete value.
First sync and subsequent changes
The server owns persisted player state. Negotiate a custom-settings capability and the intersection of registered keys during connection setup. Send no extension payload without successful negotiation. Namespaced keys, not process local registration indices, identify entries on the wire. Vanilla settings and seen/add/remove packets retain their current fields, ordinals and meaning; a vanilla client receives exactly the existing four-book traffic.
After player load and ordinary initial recipe-book packets, send one bounded
custom snapshot on the negotiated mod payload channel, before enabling custom
book UI. Snapshot includes every negotiated key, explicitly (false,false) when
absent from storage. Applying it replaces the client's custom view. Retained
unregistered or unnegotiated keys never leave the server. Snapshot delivery is
also required on imported-state replacement and every reconnect. Clear the old
client view and pending requests at disconnect; never upload that cache as a
replacement for server state.
Use reliable ordered delivery on the connection. A snapshot starts a fresh connection epoch at revision 0; each accepted server change increments the revision. Updates contain epoch, revision, key and both flags. Clients apply only the current epoch in revision order; a gap disables the custom UI until a fresh snapshot arrives. Client requests carry the current epoch and a monotonic request number; the server validates ownership of the connection's player, capability, negotiated key, booleans and bounds before applying once. Reject stale/duplicate requests without mutation; answer with authoritative state so the UI can settle. Later valid requests are processed in arrival order. Server writes use the same apply path, then send the resulting update. Notify gameplay once after an accepted change, never for rejection or an unchanged pair, through the existing settings-change moment extended to typed identity.
Cap payloads at 512 KiB and snapshots at 1024 distinct entries; updates contain exactly one entry. Reject malformed/oversized payloads before allocation or mutation, including duplicate snapshot keys. Rate-limit requests to 20 per second per player; excess requests are rejected and cannot alter flags. Client UI may show pending intent but only authoritative replies enter saved state. The design called for single-player to use the same server apply operation without serialization; this build instead preserves the existing integrated server's byte-pipe connection and identical negotiated, rate-limited authority semantics, because a typed local route needs per-connection ordered queues bound to integrated player ownership and reconnect lifetime.
Worked book and acceptance cases
A cooking mod registers demo:cooking_pot during registration and binds its
cooking screen to that key. The screen reads (false,false) initially. Opening
it requests (true,false); the server accepts, stores that pair and sends it
back. Selecting the craftable-only filter requests (true,true). A category
such as demo:soups can appear in that screen without owning either flag.
These are proposed operations, not compilable API examples.
Use these independent expectations in implementation tests:
| Case | Expected result |
|---|---|
Fresh pot and demo:oven | Both (false,false); opening pot leaves oven (false,false) |
Save pot (true,false), load, reconnect | Pot exactly (true,false) before UI opens; all four vanilla pairs unchanged |
Disconnect with pending (false,true) not accepted | Reconnect snapshot restores server's (true,false) |
| Save with mod absent, then reinstall | Retained pot (true,false) becomes accessible again; no absent key was synced |
Copy A with pot (true,false) into B; set A to (false,true) | B remains (true,false) |
Replace B containing oven (true,true) with A containing only pot (true,false) | B map contains exactly pot (true,false); oven reads default |
| Load without extension | Empty custom map; registered pot reads (false,false) |
| Unknown, wrong-side, stale or oversized update | No flags change and no gameplay notification |
Add category demo:soups | No new settings entry or vanilla ordinal |
| Vanilla peer | Existing four-book packet bytes and behavior match vanilla 26.3 |
Current routes to update
References below are verified against this worktree at 84e57e6a79; paths are
relative to crates/horizon-client/src/. Implementation must keep each route
on the shared settings operations rather than duplicate custom-map handling.
| Route | Current file:line | Required handling |
|---|---|---|
| UI toggles and send | game/items/recipe_book.rs:644, :827 | Dispatch vanilla identity to existing packet; custom identity to request |
| Remote settings application | game/items/recipe_book.rs:1339 | Keep vanilla assignment separate from custom snapshot/update |
| Server validation | lan/hub.rs:4491 | Preserve four-type ordinal validation; validate custom payload separately |
| Gameplay notification | game/network/lan/mod.rs:2853 | Notify after shared authoritative apply |
| Initial sync and reconnect | lan/hub.rs:4001, callers :1551, :1704 | Send negotiated snapshot after ordinary packets |
| Initial packet construction and resend | lan/hub.rs:63, :4077 | Preserve vanilla packet construction; include custom snapshot on replacement |
| Persistence | save.rs:4426, :4601; recipe_book.rs:926, :963 | Load/write extension with shared bounded codec |
| Host to guest and guest to host | lan/playerdata.rs:291, :633 | Clone the complete value independently |
| Import preservation and replacement | lan/import.rs:408, :460 | Preserve or replace whole map according to section presence |
| Acting-player swaps | game/network/lan_actions/turns.rs:354, :808 | Swap complete Settings, including custom map |
The vanilla reference queries are rea_vanilla.py method net.minecraft.stats.RecipeBookSettings replaceFrom and rea_vanilla.py method net.minecraft.stats.ServerRecipeBook sendInitialRecipeBook. Custom registration,
retention and negotiation are Horizon decisions, not claims about vanilla.
Recipe displays and category membership (#828)
The recipe_book_categories declaration now carries target_menu, book,
search_group and displays beside the category id, recipe type and icon.
A type supplies RecipeBookDisplayEntry values for its recipes during the
existing recipe-table declaration callback. The callback can read the Host's
recipe table and interpret its own JSON; the engine does not impose a JSON
schema on mod recipes.
RecipeBookDisplay names the shaped, shapeless, furnace, stonecutter and
smithing layouts. RecipeSlotDisplay describes slots using API-owned values:
items, stacks, tags, fuel, potion/component transforms, dyeing, trimming,
remainders and composites. All three display types explicitly derive Wire.
Named resource references are validated against the owning resource generation
at load. Display stacks are materialized when the panel's content changes.
The engine's ModBookIndex::build boundary validates declarations atomically
against resource tables. Unknown resources, invalid layouts, duplicate recipe
ownership and conflicting book membership return DisplayLoadError values.
A book has one menu and one search group; no search group can aggregate two
books. Search membership is derived from the book's categories, so two mod
books remain isolated and vanilla books have no mod search members.
The live recipe-table declaration path validates the entire batch before publishing any categories. A refused batch retains its typed load error for that generation and publishes no partial book; reload or disconnect clears the cache. Multiple categories may share a recipe type. The engine's panel projection requires both the declared target menu and registered book key, and a category selected in another book cannot be reused as a tab here.
Hosting the panel in a mod screen
Construct Screen with the geometry your menu needs and use struct update syntax
for fields you do not set. This keeps the literal compiling when optional fields
are added; it does not make the default geometry suitable for your menu.
#![allow(unused)] fn main() { let screen = mod_api::Screen { width: 176.0, height: 166.0, slots: vec![Some([44.0, 35.0]), Some([116.0, 35.0])], inventory_x: 8.0, inventory_y: 84.0, ..mod_api::Screen::default() }; }
Set Screen.recipe_book to Some(key.clone()) in the existing native_screen
declaration. Leave it None for a screen without a recipe book. The key must
belong to the categories' target_menu; a different menu cannot display that
book. The green book button sits just outside the menu's right edge so it does
not cover slots chosen by the mod. The open panel uses the normal narrow-window
coverage and wide-window menu shift.
The panel is disabled until the connection has an authoritative settings snapshot for the registered key. Opening and filtering submit requests through the shared custom-settings route; neither click optimistically changes the saved flags. Tabs show this book's category icons, with a search tab aggregating only this book. Four category tabs fit on each tab page; separate arrows reach additional categories while search stays visible. Search matches displayed result names. The craftable filter matches the display's required input alternatives against player inventory and visible menu contents, not the custom recipe's opaque JSON logic.
Recipe buttons cycle through their declared result displays. Clicking a button opens a display preview using the shared recipe-book overlay painter, including stonecutter and smithing ingredient layouts. Larger ingredient grids scale to fit the preview instead of spilling into adjacent alternatives. These previews do not place recipes, create ghost slots, or send a custom id through vanilla placement packets; placement and ghosts are tracked separately in #2599.
A declaration's book is a registered RecipeBookTypeKey from #2415; it does not
implicitly register a book or create settings. The index checks the key against
the frozen registry. No vanilla book ordinal or packet meaning changes.
Transport and cost
The dedicated horizon:recipe_book_settings channel is used only after the
server announces mod transport and the client offers its frozen registered
keys. The server intersects those keys with its own registry before replying
with the initial snapshot. Vanilla clients never send the offer and receive no
custom settings. The bounded binary codec refuses oversized payloads, invalid
flags, trailing bytes and duplicate snapshot keys before applying anything.
Negotiation, snapshots and requests do work only on connection or a settings change. There is no per-tick mod scan or new tick allocation. A connection's request replay guard and rate allowance survive rejected requests and repeat offers. Disconnect removes the connection authority and clears pending client intent; the saved server map remains independent of the client view.
The response budget covers every payload, including malformed input and snapshot requests. Once exhausted, a negotiated peer receives a one-byte busy control reply instead of another large snapshot. The client disables the custom view and coalesces a retry after one second; a busy response schedules the next retry, and a successful authoritative snapshot cancels it. This bounds response amplification without leaving a lost resnapshot or pending UI intent unresolved.
Toggle layout and large books
Screen.recipe_book_toggle optionally reserves a 20 × 18 GUI-pixel toggle at
an [x, y] offset from the menu origin. Like vanilla's screen-specific button
positions, this is space the screen author must keep clear of slots and labels.
Both native and wasm declarations carry the same optional value over Wire.
Without an anchor, the client tries an external right, left, then above-menu
gutter, rejecting positions covered by the overlay panel. Menus wider than 176
GUI pixels, windows narrower than 379 GUI pixels, or windows narrower than the
menu width plus 202 GUI pixels use overlay mode instead of shifting slots. If
the toggle does not fit in the viewport in the closed layout or in the open
layout, the book is refused with a diagnostic identifying the mod block and menu.
The diagnostic requests Screen.recipe_book_toggle when absent, or reports an
anchored toggle outside the viewport when the declared anchor does not fit. The menu still opens unchanged; the
engine never clamps a toggle onto mod slots or clips the menu to make room.
The client caches resolved result/search metadata for the screen binding. Stock changes re-evaluate craftability and filtering, but only the selected page's twenty recipes materialize ingredient previews. Search still considers the entire declared book, not just the visible page.
Menu-owned destination roles
During shared registration,
r.menu_recipe_book(menu, &book, recipe_type, layout, screen) binds a content
menu to a registered book, a recipe type and its existing typed Screen value. The menu namespace
must belong to the registering mod. Duplicate bindings and unknown books refuse
installation even when the registration error is ignored. The declaration is
frozen with the other registrations and explicitly derives Wire, for the
same native and wasm transport.
RecipeBookLayout declares one of four destination maps:
Crafting { width, height, slots, result }supports shaped and shapeless displays. Slots are row-major container indices.Furnace { input, fuel, result }supports cooking displays.Stonecutter { input, result }supports stonecutting displays.Smithing { template, base, addition, result }supports smithing displays.
These are container slots, not the appended player inventory. Overlapping roles,
invalid dimensions and indices outside the 256-slot menu bound refuse registration.
Book loading checks against the actual content menu size. If the menu has an
instantaneous MenuCrafting backend, its grid and computed result must exactly
agree with the declaration. Without that backend, a crafting map describes real
persistent input storage instead: for example, six cooking-pot inputs in a 3 by 2
grid. Placement never fills its cooked output or fuel, and preserves those slots.
An incompatible display or one that does not fit refuses the declaration
generation rather than truncating ingredients. All mapped slots need finite
coordinates in Screen.slots. Registration rejects missing or nonfinite
coordinates, nonpositive/nonfinite panel sizes and a screen bound to another
book. Startup also rejects geometry for slots beyond the actual content menu.
The screen is frozen in this same declaration, not a second registry. For these
book-enabled menus, both the client layout and server hidden-slot policy use
that registered screen; native_screen is not consulted. Other mod menus still
obtain their screens dynamically.
The shared mapper uses vanilla PlaceRecipeHelper: a 1 by 1 shaped recipe in
a 3 by 3 grid is centered; a 2 by 2 recipe starts at the upper left and retains
its holes. Shapeless ingredients occupy consecutive cells. Furnace fuel is
included in a preview only when its slot is empty. Station icons never map to
menu ingredients. Result display stacks retain their components and counts.
Authoritative placement information
A menu-bound recipe type supplies RecipeBookDisplayEntry.placement as
Some(PlacementInfo { cells, shape }). Each cell is either an authoritative
Ingredient predicate or an empty position; shape: Some([width, height])
preserves shaped holes, while None gives the menu's ordered inputs. The recipe
type owns these predicates, including custom component-sensitive tests. Display
pictures never substitute for them. Use PlacementInfo::NOT_PLACEABLE explicitly
for a preview-only recipe. Omitting placement information from a bound recipe
refuses its declaration load rather than guessing ingredients from its icons.
The shared Wire boundary allows 32 structural record levels, including the
category, display-entry and placement-info envelope. Nested ingredient shapes
must fit that complete envelope; the native validator and wasm boundary use the
same codec and refuse excess depth with WireError. Opaque NBT has its separate,
iterative vanilla limit of 512 container levels; it does not spend one structural
level per NBT container. Wide, shallow cooking grids do not consume extra depth.
Bound menus use the server's negotiated catalogue, not a reconstruction from local client recipe JSON. Each request names the exact published recipe and original display alternative, scoped to the connection epoch, open window, screen nonce, server menu instance and declaration generation. Left click places one increment; Shift requests the maximum. Right click opens the alternatives preview, whose left-click selection preserves the original source index even after filtering and craftability sorting. Unbound books remain preview-only.
The server tests the recipe type's authoritative predicates against exact component-aware stack classes and rechecks its live context after callbacks. It commits placement atomically, or refuses without publishing a partial plan. Persistent machines retain their real fuel and cooked output; placement does not craft their output. Only a current server missing-ingredients response installs ghost hints. Every new click, window change, disconnect or declaration reload revokes pending hints. Ghosts use the shared vanilla painter and the registered screen's native slot geometry, never built-in slot coordinates.
Displays are pictures, not ingredient predicates; declaring a result ghost never
grants a real stack. NeoForge 26.3 public documentation names no registration
counterpart for vanilla's internal GhostSlots.setInput and setResult; these
declaration names are Horizon's.
Design: decide, fire, apply
Status: accepted with changes by the maintainer decision, combining both review views. The requirements below must be settled before runtime code; the note remains a provisional design, not implemented behavior.
Contract for #784, for Ari's review and decision on the issue. This is a documentation proposal, not implemented behavior. The named question is the decision token and stale-apply semantics across client-lib and menus.
Evidence and limits
Vanilla 26.3 was inspected from /workspace/horizon with python3 tools/rea_vanilla.py method net.minecraft.world.inventory.AbstractContainerMenu clicked. Its entry calls doClick(slotIndex, buttonNum, containerInput, player) synchronously. Exceptions become a reported crash with menu type, class, slot count, slot, button and input type. This verifies the entry and exception boundary only; it does not establish a reusable decision token, atomic rollback, or all downstream click semantics. Downstream vanilla callers and individual station algorithms remain to be pinned before runtime migration.
The issue explicitly names no NeoForge 26.3 counterpart (Horizon design). The permitted 26.3 AbstractContainerMenu Javadoc could not be accessed by the web reader. NeoForge's downstream click, cancellation and transaction behavior is consequently unverified; no equivalence is claimed. No NeoForge source, patches or other project's source were consulted. Public docs must supply any future counterpart claims.
Proposed contract
Client-lib decides using an immutable view; game dispatches the existing named hook with the catalog-selected Host; client-lib applies the frozen plan after validation. Lower crates never construct a Host or depend on Game. This is a control-flow boundary, not three new public hooks. Retain one hook per vanilla moment, and remove replaced transports and all callers together without compatibility aliases.
A decision is an internal, opaque, non-cloneable, single-use value. It contains action kind, side, actor lifecycle holder, world/session identity, target lifecycle holder, transaction/savepoint identity, proposed ordered mutations/effects, and a typed read set. Menu decisions additionally bind the menu instance generation, container identity, selected recipe and registry generation, slot layout/rules generation, cursor, input slots, output slots, capacities, permissions, costs, drag state and backing-storage versions actually consulted. Full stack components participate, not just item IDs/counts. Capture RNG state/draw position where relevant; decide must not consume shared RNG or publish effects. Store planned draws or reserve them transactionally. Never expose the token as mod-controlled bytes or retain borrowed menu pointers across dispatch.
Versions advance on every relevant mutation, including changes restored to equal values (ABA). The read set is operation-specific: an unrelated slot outside it need not invalidate a decision. Global identity checks always apply. Complete mutation epochs are required even in the interim: exact snapshots cannot detect ABA. Where per-slot mutation coverage is incomplete, conservatively invalidate the whole menu with a complete menu mutation epoch; snapshots may supplement, never replace, epochs. Do not silently accept an incomplete read set for performance.
Apply returns a proposed typed outcome: Applied, Canceled, Stale(reason), InvalidInput(reason) or ListenerFailed. Busy is an admission error before token creation, dispatch or mutation, not an apply outcome. Existing public operation results remain the only public result API: Applied maps to the existing success result; Canceled to that hook’s existing cancellation result; Stale and admission Busy to the operation’s existing refused/no-change result; InvalidInput and ListenerFailed to its existing failure/refusal result, with runtime diagnostics. Preserve each operation’s existing return shape and cancellation semantics; export neither tokens nor a second transaction API. A no-action decision dispatches nothing. Validate once before firing and again immediately before mutation against the transaction's live overlay, including listener writes. Deferred actions validate again at their ordered execution position. Invalid identity, changed cursor/slot/rule/recipe/permission or invalidated world refuses the original operation. Never rerun decide, retarget, reselect a recipe, repeat dispatch or retry automatically. A later player input can request a new operation. Consume the token on every terminal outcome; a second apply is invalid input.
Entity identity uses the existing lifecycle holder (server-wide epoch plus id), resolved through the entity table lookup as Found, Taken or Missing (#1606). No second entity generation is introduced. Missing is stale; Taken at admission is Busy, or during apply is Stale, never a retry.
Hook-specific mutable output is distinct from token preconditions. Validate that output against the unchanged decision inputs, then apply it once. For example, an anvil output override is allowed by its hook; replacing the input stack via Host invalidates the decision. Preserve each existing hook’s name, side and cancellation meaning rather than assigning a universal item-loss policy.
Transactions and reentrancy
Open an action child savepoint under the existing Host transaction before decide. Listener writes and apply share that action boundary. Nested listener commits merge into the action, never publish independently. Cancellation, stale refusal, invalid output or recoverable failure aborts the action, including listener writes, inventories, cursor, costs, RNG, deferred drops, notifications and packets. Outer abort also undoes an accepted action. Root commit alone publishes effects. Inside a lifted pass, defer drops, entity spawns, attachment-holder retirement and journal emission to root commit, following the placement note (#2319); rollback cannot un-retire a holder. Preserve its rollback ordering: discard listener pending writes/effects, undo listener Sim writes, reverse action mutations, restore actor/inventory/RNG, then close aborted. Listener reads of other entities use entity rows refreshed at tick exit; tentative entity changes remain invisible until commit.
An open action savepoint counts as a lifted pass for the save gate (#2211 and follow-up). Refuse every save during it, including listener save-all, with the existing save-failed message; the lease foundation alone is not the save gate. Use game/runtime/transactions.rs:174,179,303,399,462,513 for the existing open/close, menu journal, deferred effects, commit and rollback owners; these facilities are foundations, not proof of a complete menu/Sim journal.
Reentrant close/replace or same-menu click invalidates the outer decision. Refuse recursive execution on the active menu with Busy, before dispatch or mutation; allow other-object work only under nested savepoints. A requested close may be staged, but if it invalidates the outer action the whole action aborts, including that close. Do not retain a mutable borrow while firing. Restore lifted storage and release guards on all unwind paths. Multi-step quick move and recipe placement are one action with an ordered plan; no partial successful prefix on refusal. Post-commit notices remain separate moments and cannot retroactively veto committed writes.
Scope and inspected owners
Paths below are relative to this worktree's base. These are verified existing entry owners, not claims that their complete transitive caller graph is already migrated. The implementation must finish the vanilla caller oracle and per-route trace before claiming #784 complete.
| Hooks or route | Current owner and proposed treatment |
|---|---|
item_stacked_on_other | crates/horizon-client/src/menu.rs:384,3422; authoritative hub path crates/horizon-client/src/lan/menu.rs:926. Freeze cursor and both stacks before dispatch; apply one validated answer. |
menu_slot_rules | crates/horizon-mod-vocab/src/hooks/base.rs:2123; crates/horizon-client/src/menu.rs:610,622,1894,2596; LAN crates/horizon-client/src/lan/menu.rs:469,658. Rule answers become plan inputs; no recursive hook invocation during validation. |
| Local menu click, cursor, quick move, drag | crates/horizon-client/src/menu.rs:3208,3589,3600,3626,3642; share one coordinator, including throw/swap and output-taking branches of click. Preview remains read-only and cannot commit or dispatch authoritative server hooks. |
| LAN authoritative clicks/backing storage | crates/horizon-client/src/lan/menu.rs:784,227,283; bind authoritative guest/menu generations and validate backing storage before write-back. Dedicated and single-player server routes use this same contract. |
| Recipe-book and mod crafting placement | crates/horizon-client/src/game/network/lan_crafting.rs:789,974; bind recipe, all selected source slots and destinations; refuse changed inputs without choosing replacements. |
| Anvil and grindstone prepare/take | crates/horizon-client/src/menu.rs:1389,1401,1412,1425,1440,2059,2073,2084; migrate paired query/write-back helpers to tokens without merging distinct prepare and take moments. |
| Menu close and merchant state | crates/horizon-client/src/menu.rs:3794,3865; invalidate generations and journal cursor/inventory settlement. |
| Motion hooks: living_jump, living_jump_power, living_speed, living_flying_speed, block_jump_factor, block_speed_factor, player_stays_on_ground_surface, fluid_flow | crates/horizon-client/src/motion.rs:52,59,67,75,83,91,113,125. Out of scope: retain value-in, value-out fires with Host. Tokens/read sets add per-entity per-tick cost and provide no stale-apply protection for pure value transforms. |
| Other catalog client-lib targets | crates/horizon-mod-vocab/src/hooks/base.rs:2520,3424,4781,4793,4805,4815 owns living_breathe, ingredient, living_drown, living_fall, living_heal targets (living_jump is excluded above). hooks/ingredient_test.rs:15, recipe_property_sets.rs:20, ender_entity_teleport.rs:14, farmland_trample.rs:13, player_name_format.rs:14, player_tab_list_name_format.rs:15 own remaining explicit client-lib targets. Each needs a typed decision or an explicitly read-only fire boundary, not a Host-less escape hatch. The read-only exception is only a dispatch shape; every hook still receives Host. |
| Generator/game dispatch | crates/horizon-mod-api-gen/src/lib.rs:547 owns trait generation; crates/horizon-client/src/game/native_hooks/fire.rs:5 includes generated dispatch. Generate typed side/Host signatures and native/wasm adapters from one catalog. |
Remote prediction never gains server authority. Client-side hooks receive ClientHost; server menu mutations receive ServerHost; both-side moments use typed HostSide. Network corrections may be sent after refusal to reconcile prediction, containing authoritative pre-action state; no success effects escape. Non-menu targets above are migration scope, but their precise read sets require individual oracle fixtures; this note does not invent their vanilla algorithms.
Trust, panic, target parity and cost
Validate untrusted mutable hook outputs and Host requests at the callback boundary. Internal typed values guaranteed by construction need no repeated structural validation in apply; apply rechecks live preconditions only. Validate owned IDs, generations, enum discriminants, slot indices, stack components/counts and capacities; reject non-finite numeric outputs and checked-arithmetic overflow. Preserve existing documented clamps where the hook owns them; do not turn unrelated unknown IDs from no-ops into errors. No arbitrary token deserialization, pointers or caller-supplied version numbers. Set finite read-set, plan-size and recursion budgets before runtime rollout; overflow refuses atomically, never truncates a plan.
Menu routes require #2232 and tracking issues #2244 and #2240 before migration: clicks run under as_guest, and a panicking listener must leave the guest swap restored.
Catch native unwinding at game dispatch; guest traps use the same ListenerFailed outcome and rollback. Log/report the failing listener under runtime policy. Process-aborting panics and rollback failure cannot be promised recoverable. A subsequent built-in action must work after a caught failure. Native and wasm expose the same hook/Host contract and ordered dependency dispatch; wasm handles are scoped, checked resources, not transferable decisions. Any changed public payload requires a shared-memory Wire representation and generated coverage row. Unsupported capabilities must fail registration/load with a named UnsupportedHookCapability refusal identifying the hook/capability, for both targets. Internal tokens need no Wire form. Leave frozen injection-point machinery untouched. Update generated bindings, docs and samples together.
Resolve subscribers at load. With no listeners, take the ordinary operation directly with zero token/read-set/payload allocations and zero all-mod scans; preserve enclosing transaction guarantees. Subscribed cost is proportional to consulted state and planned writes. Validation reads stored rule results and versions; it must not re-fire callbacks or recalculate an alternative action.
Acceptance matrix for implementation
These values are proposed requirements, not passing runtime tests. All refused cases publish zero success packets, drops and costs.
| Fixture | Expected value |
|---|---|
| Cursor stone 3, empty slot, accept ordinary placement | Cursor 0, slot stone 3, one apply and one dispatch. |
| Same fixture, listener cancels after writing unrelated slot dirt 2 | Cursor stone 3, destination empty, unrelated slot restored, zero apply. |
| Listener changes cursor to stone 4 and allows | Stale; cursor restored to stone 3, destination empty, one dispatch, zero recomputations. |
| Input stone 3 changes to dirt 3 then back within listener | Stale by version; original state restored; zero apply. |
| Menu generation 7 replaced with identical generation 8 layout | Stale; no write to replacement; replacement rolled back with action. |
| Quick move stone 10 into capacities 4 and 6 | Accept gives 4 and 6, source 0; listener changes either capacity: entire move refused, source 10. |
| Recipe needs two stone; source stone 2, output empty | Accept consumes 2 once; listener removes one input: stale, source restored to 2, output empty. |
| Nested transaction commits dirt 2, outer listener traps | Dirt write rolled back; next ordinary stone placement succeeds. |
| Reentrant same-menu click | Inner admission Busy with zero token creation or dispatch; outer proceeds only if unchanged. |
| Entity table returns Taken | Admission Busy or apply Stale; zero retry or retarget. |
| Spawn/drop then cancel in lifted pass | No spawn, retirement or journal publication; existing holders survive. |
| Save during action savepoint | Refused; no tentative state persisted. |
| Guest listener panics | Guest swap restored, action aborted; next built-in action succeeds. |
| Listener reads another tentatively changed entity | Reads tick-exit row until commit. |
| Changed public payload / unsupported capability | Wire and generated coverage on both targets / named load refusal. |
| Motion value transform | Existing Host-bearing value fire; zero token/read-set allocations. |
| Duplicate apply/token from another session | InvalidInput, zero mutations. |
| NaN, infinity, invalid slot, overflowing count from listener | InvalidInput and complete action rollback; next built-in step succeeds. |
| Outer transaction aborts after Applied | Original cursor, slots, costs and RNG restored; zero published effects. |
| Native versus wasm, identical listener sequence | Same outcome, stacks, effects and dispatch order. |
| No subscriber | Same final state/RNG/effect order as baseline; zero decision allocations. |
Proposed implementation stages
File no issues or PRs from this note.
- Before code (PR 1), confirm downstream NeoForge menu click, cancellation and transaction contracts from public method/event documentation before assigning counterpart contracts to individual hooks; inventories and class-level coverage do not establish semantics. Finish downstream vanilla oracle/caller fixtures and the accepted token, refusal and rollback requirements on #784. Inventory every generated client-lib target, including targets without explicit annotations.
- Add internal typed tokens, mutation generations and action savepoints to the existing coordinator; prove rollback and overlay validation with adversarial fixtures before exposing callbacks.
- After #2232, #2244 and #2240 guest restoration prerequisites, migrate menu routes together, coordinating #2318 capacity ownership, #2259 mutable argument write-back and #2249 transaction fixtures. Remove old transports and update generated native/wasm bindings and samples.
- Excluding the eight motion value transforms, migrate remaining client-lib targets in bounded slices with their individual read sets and side contracts. Run parity, failure recovery and allocation measurements before closure.
This unblocks the Host-bearing client-lib/menu half of the clean break, and supplies the stale-action foundation for #855 multi-block placement and #731 screen initialization where applicable. It does not settle #781 attachment Data storage, #792 entity storage or #796 identity by itself; those contracts supply identities this mechanism must validate.
Open questions
- Version granularity: whole menu or consulted slots? Per-slot/rule epochs plus mandatory menu generation are preferred; complete whole-menu mutation epochs are required until per-slot coverage is complete. Only the eventual granularity remains open; snapshots alone are never sufficient.
- Reentrant close semantics: should a listener close survive refusal? Atomic rollback with the action and admission Busy for recursive same-menu execution are required here; confirm operation-specific public refusal mappings in each route inventory before code.
- Budgets: which numeric limits? Recommend measuring maximum valid recipe/menu plans first, then freezing explicit limits and boundary tests; no arbitrary guessed limit in this proposal.
- Legacy clamp versus rejection: recommend retaining each hook's documented range/clamp and rejecting malformed structural output. Review each migrated declaration rather than imposing a new universal numeric policy.
- Complete route evidence: downstream vanilla and implicit generated targets remain unverified. Recommend making the route inventory and individual oracle fixtures a merge prerequisite for runtime implementation; this accepted provisional note is not evidence of completion. NeoForge downstream menu contracts remain unverified and public documentation confirmation is a PR 1 prerequisite.
Design: effect added
Accepted with changes for #835, per the maintainer decision. This remains a provisional design, not an implemented contract. The additional requirement withholds adoption of the pre-apply Added moment until the public NeoForge 26.3 contract is verified.
Evidence and vanilla moment
Inspected at base 95672cb54f using python3 tools/rea_vanilla.py from /workspace/horizon: LivingEntity.addEffect overload 1, LivingEntity.onEffectAdded, and MobEffectInstance.update. No NeoForge source, patches or other project's source were consulted.
Vanilla 26.3 first calls canBeAffected; refusal returns false. It looks up the active instance. An absent effect is inserted, onEffectAdded runs, changed becomes true, and the incoming instance's onEffectAdded runs. An existing instance is updated; only a true update result calls onEffectUpdated with forced=true and sets changed. Afterwards incoming.onEffectStarted runs even when update returned false. LivingEntity.onEffectAdded marks effects dirty, adds attribute modifiers and sends to passengers on the server. These are three distinct moments: first insertion, update, and accepted application attempt. Hidden-chain updates may mutate state while update returns false; false does not mean nothing changed. Stronger short effects preserve the former active instance in the hidden chain; equal-strength longer effects extend duration. Visibility/icon/ambient changes can count as updates.
The issue names MobEffectEvent.Added. The public 26.3 Javadoc request failed as inaccessible. Public living-entity docs were reachable but identified themselves as 26.1 and did not establish Added's 26.3 contract. Its exact timing, side, cancellation, payload and unchanged-update semantics are unverified. Do not infer them from its name or from vanilla onEffectAdded. Public verification is a prerequisite to implementation and may change the provisional moment below.
Proposed ownership and contract
Game owns the authoritative effect-add coordinator and the paused continuation. Lower effect stores own pure applicability/update decisions and versioned apply primitives; they never depend on Game or dispatch hooks. Every authoritative producer requests this operation with a stable LivingRef, incoming full instance and optional source. The coordinator covers vanilla and registered content effects identically.
The following moment is not adopted. Provisionally define Added as one notification for each application admitted by canBeAffected, including replacement, extension and unchanged/hidden-chain updates. Fire after applicability, before mutating the effect store. Payload freezes incoming and previous instances, target LivingRef and optional source; provide mutable ServerHost. Added is a notice, not another applicability veto. Its return does not replace the instance. This recommendation deliberately does not equate Added with vanilla's first-insertion-only onEffectAdded. Approve only after checking the published counterpart.
Follow CLAUDE.md's decide, fire, apply rule: decide captures target identity/generation, dimension, residency and the relevant effect-store version, immutable input and exact continuation phase. Outside lifted passes, callbacks read current holders and write through Host. Inside a lifted pass, do not restore rows mid-pass: listeners read entity rows refreshed at tick exit, and listener writes queue for application after that entity's tick at its exit apply point. A heavy read of another mid-pass entity must return a narrow Lifted error. These read rows and the write queue are prerequisites, not existing coordinator capabilities. Apply listener writes and the stored continuation in one transaction. Revalidate after dispatch; if the target was removed, changed dimension, replaced, or its effect read set changed, refuse the stale continuation without recalculating it. Aborting that action rolls back its listener child writes. Do not overwrite the holder with a cloned pre-callback row. Unrelated actor writes survive a successful transaction.
An explicit nested add requested by a listener is a distinct operation with its own notification. Execute it in deterministic transaction order, bounded by a declared nesting budget. If it changes the outer target's read set, refuse the outer operation; never replay its notification. Nested additions merge into the outer action and roll back with it when it becomes stale. Define how coordinator refusal maps onto public operation results separately from vanilla update false, which may retain hidden-chain mutation. Count notifications at dispatch, including a subsequently refused continuation, and count committed additions separately. There is no exactly-once committed-event claim for a pre-apply notice.
Keep vanilla first-insertion behavior and effect-type onEffectAdded separate from the public Added notice because vanilla executes different work between those moments. In game/items/potions.rs:1503, mod_effect_added_resolved currently filters to content effects and multiplexes new-only native_on_effect_added with native_mob_effect_added. Split those responsibilities by their actual moments, remove the content filter for Added and delete duplicate caller notifications. game/modding/native_living_effects.rs:138 must enter the coordinator instead of notifying independently. Update the catalog, generated adapters, hook notes and consumers together; retain no old numeric-ID alias.
Before implementation, verify the published counterpart's timing, logical side, mutability, cancellation, source/previous payload and unchanged/hidden-update behavior using public documentation only. Write the exact catalog signature and Host observation guarantees from that evidence. Today's catalog places Added after the first-insertion callback; moving it before mutation changes Host reads. If the verified counterpart is after mutation, preserve that public moment; internal stale tokens must not move it. Class-level coverage and vanilla algorithms do not establish this contract.
Define API-owned snapshots for incoming and previous full instances, effect keys, optional source identity and hidden chains; keep engine store types internal. Explicitly settle whether the source can be nonliving rather than narrowing it accidentally to LivingRef. Require shared-memory Wire layouts, generated coverage updates and registration/load refusal for unsupported payload capabilities; existing Hooks::mob_effect_added coverage does not prove redesigned payload support. Keep frozen injection-point machinery untouched.
Route owners to migrate
Paths below are relative to crates/horizon-client/src/; line numbers identify inspected current owners, not promised APIs. All rows enter the same coordinator; no producer gets its own Added dispatch.
| Route | Verified owner |
|---|---|
| Drink | game/items/potions.rs:987 lasting effects; instant actions at the following loop are separate from stored-effect additions. |
| Splash | game/items/potions.rs:1054,1100 player and mob writes. |
| Lingering and cloud | game/items/potions.rs:2060,2286,2460 player/cloud and mob application; cloud creation itself emits zero Added. |
| Beacon | game/blocks/beacon.rs:320,322 direct primary/secondary writes. |
| Conduit | game/blocks/block_entity_anims.rs:686 direct power write. |
| Command | game/administration/commands/effects.rs:71,95 player and mob branches. |
| Consumption | game/items/consume.rs:78; mob eating and conversion behavior at game/mobs/creatures/mod.rs:903,3827 and game/mobs/zombie_villagers.rs:296. |
| Mob attack and AI | game/mobs/creatures/mod.rs:2898,3590,6439,7209,7280,7282; wither at game/mobs/wither.rs:227; trading at game/items/trading.rs:470. |
| Enchantment | game/items/enchant_effects.rs:545. |
| Raids | game/mobs/raids/mod.rs:537,731,1543. |
| Sculk | game/blocks/sculk.rs:1573. |
| Sulfur | game/blocks/potent_sulfur.rs:104,277 mob and player. |
| Other block entity | game/blocks/block_entity_anims.rs:372 glowing. |
| Creature player effect | game/mobs/creatures/mod.rs:4595 direct write. |
| Native handle | game/modding/native_living_effects.rs:96,115,138. |
| Lower mob store | mobs.rs:4715,4731,5024 wrappers and instance update; convert gameplay callers to resumable decisions, not a callback reaching upward. |
| Player store | game/items/potions.rs:1409,1422 applicability and update. |
Before migration, classify every producer by whether its actual call path runs inside a lifted pass. Commands, beacon, conduit, drink and splash from item use, and consumption outside passes migrate first. Mob attacks, raids, sculk and other mob AI wait for entity read rows and the write queue; consumption reached from mob AI belongs to that later group. Audit remaining routes rather than assuming their phase. Extend the census to turtle helmet, witch, illusioner, axolotl, contact effects, pufferfish and lower consumable paths.
This census identifies write owners, not a completed vanilla caller parity proof. Initialization/deserialization, network replication and test setup must be classified explicitly: restoring an already-active effect emits zero Added. Audit direct store mutations and trial-spawner initialization (game/mobs/trial_spawner.rs:353) before implementation; do not silently turn load/spawn restoration into application attempts.
All living holders use stable handles: local authoritative player, LAN guest, ordinary mobs, bosses and living holders currently refused by native writes (armor stands and dragon). Preserve vanilla immunity/override behavior; do not broaden acceptance merely to provide a handle. Resolve holder capabilities through the residency table lookup: dragon belongs to the design's 20 residency kinds and must not acquire a native-only exclusion. Verify armor stand applicability through the same capability model. Native API currently refuses these holders (native_living_effects.rs:90); holder capability parity requires explicit verification. Nonliving entities cannot be targets. Integrated single player, LAN host/guest and dedicated server use the same authoritative coordinator; remote prediction and inbound effect packets emit zero server Added. Exercise real guest routing rather than treating temporary player context as holder identity.
Residency, safety and cost
Use the existing residency boundary from #1606 and the placement design. Within a pass, retain lifted ownership and use the read rows and queued writes at the entity exit apply point; reacquire by generation there. Do not require restoring all lifted rows before dispatch or describe the planned write queue as already built. Balance lifted-pass leases on success, refusal and unwinding; no duplicate raw entity-list path. Preserve listener-spawned entities and attachment/join identities; defer retirement and publication to root commit. Saving while a lifted pass/action is open must refuse rather than persist partial state.
PR #2297 and PR #1863 were OPEN when inspected. The former carries the latter and modifies consume, fox and native effect integration. Neither establishes complete effect-add unification. Coordinate with their paused mob continuations and preserve their ownership restoration; recheck merged owners before implementation. Do not introduce a competing mob lift. PR #2112 is also OPEN; its mob-listener unwind restoration and stack are dependencies to coordinate with, not merged guarantees.
Mod input is untrusted: validate registry ownership, handle generation, dimension, duration (-1 infinite or nonnegative ticks), amplifier clamped to 0..=255 and flags at the handle boundary. Reject duration below -1 and unknown effect keys without changing unrelated no-op behavior. Validate finite numeric inputs before conversion where an adapter accepts floats; measure hidden-chain/nesting budgets without rejecting vanilla-valid instances or silently truncating valid state. Catch native unwinding and guest traps at dispatch, abort the action transaction, restore lifts and use runtime failure reporting/disable policy. Process-aborting panic cannot be recovered.
Resolve subscribers at load. No listeners means no payload allocation, cloning of hidden chains, event queue or per-tick scan; use the same vanilla update primitive directly with existing transaction guarantees. Subscribed cost is proportional to listeners and the touched effect chain. Never scan every mod or every living holder to find additions.
Acceptance matrix
Required future tests, not validation claimed by this note. Counts follow the provisional admitted-attempt definition above.
| Fixture | Concrete expected values |
|---|---|
| Each route above, fresh speed 200 ticks amplifier 0 | Exactly 1 Added, target exact LivingRef, previous=None, final speed=(200,0). Use route-specific effect where fixed by vanilla: conduit_power, darkness, nausea, poison, wither or glowing. |
| Existing speed=(200,0), incoming=(400,0) | 1 Added, final=(400,0), zero first-insertion effect-type callbacks. |
| Existing speed=(200,0), incoming=(100,0) | 1 Added, final=(200,0), return false. |
| Existing speed=(200,1), incoming=(400,0) | 1 Added, active=(200,1), hidden=(400,0), return false; hidden mutation retained. |
| Stronger short speed=(40,2) over (200,0) | 1 Added, active=(40,2), previous retained hidden with duration 200. |
| Applicability refuses poison on immune target | 0 Added, no inserted poison, return false. |
| Listener removes target or changes its effect version | 1 dispatched Added, 0 committed outer additions; stale write refused, no retarget to recycled ID. |
| Listener explicitly adds another effect | 2 notifications total; both apply if independent; same-effect nesting invalidates outer continuation. |
| Listener changes another mob, moves actor and spawns a mob | Changes survive successful apply; no old row restoration erases them; attachments/join numbers preserved. |
| Panic/trap; invalid duration -2; unknown key | Panic rolls action back and balances lift; invalid inputs yield 0 Added. Next built-in application succeeds once. |
| Each producer inside a lifted pass, listener panics | Action rolls back; existing mob-listener restoration runs; lifts balance on unwind, refusal and success; next entity/application remains usable. |
| In-pass reads and writes | Read rows reflect tick-exit refresh; heavy read of another mid-pass entity returns Lifted; writes apply only at the owning entity exit point. |
| Listener spawn/removal and save during open action | Publication and retirement wait for root commit; rollback publishes/retires neither; save refuses even without an open lift. |
| Holder capabilities and payload transport | Dragon resolves through residency lookup; armor stand applicability is verified; native and guest snapshots preserve chains/source identity; unsupported capabilities refuse registration/load. |
| Verified public moment | Host observations and exact catalog signature match published timing, side, mutability and cancellation; pre-apply moment remains unadopted until verified. |
| Integrated, LAN guest and dedicated | Same fixture yields 1 server Added each; replication yields 0 additional Added. |
| Load/replication/instant damage with no stored add | 0 Added. |
| No subscribers | Same active/hidden state and return values as ordinary update; 0 event allocations. |
Proposed implementation stages
File no issues from this list.
- Before any code, verify the public NeoForge 26.3 Added contract. Confirm timing, cancellation, payload, side and mutability using public docs only; record the exact signature and observation guarantees. The pre-apply moment is not adopted until then. Complete the vanilla caller census, phase classification and holder capability fixtures.
- Introduce pure lower-store decisions and the Game-owned coordinator with stale checks for producers outside lifted passes first: commands, beacon, conduit, item use and consumption outside passes. Require payload/Wire coverage and refusal behavior before exposing the API.
- Wait for entity read rows and the write queue before migrating mob-pass producers, including attacks, raids, sculk and other mob AI. Apply queued writes at entity exit and reuse accepted mob-listener unwind restoration; never restore rows mid-pass or migrate these producers first. Remove duplicate/content-only dispatch as each route migrates and update generated API/docs atomically.
- Run the full route/holder/server matrix, per-producer in-pass panic and balanced-lift fixtures, nested rollback/result mapping, save refusal and allocation/budget measurements before claiming #835 complete.
Open questions
- What exact moment does published 26.3 Added cover? Recommendation: admitted application attempts, including unchanged updates, provisionally; verify Javadoc before implementation and follow Ari's decision if different.
- Can a pre-apply Added be observed for a refused continuation? Recommendation: yes, explicitly distinguish dispatched from committed counts; never retry a stale decision. If committed-only notice is required, redesign the moment before coding.
- How broad must holder capability support be? Recommendation: every vanilla living holder with its real applicability overrides; verify armor stand/dragon behavior rather than retaining blanket native exclusions by accident.
- What nesting and chain budgets apply? Recommendation: deterministic runtime-wide measured limits with transaction refusal, shared with existing Host operations; no silent truncation.
- What exact catalog payload, source identity (including nonliving sources), Wire layouts and public refusal result follow from verification? Requirement: settle these before exposing code; reject unsupported capabilities at registration/load.
- When may in-pass producers migrate? Requirement: only after read rows, write queue and shared unwind restoration exist; outside-pass producers go first.
- What may merge ahead of the carry PRs? Recommendation: this docs note only; implementation must integrate the accepted continuation ownership and world-visibility decision.
Design: entity render state
Provisional answer to #897:
introduce one owner-thread entity preparation operation, after complete base and
specialized extraction and before model posing or geometry emission. All entity
routes call it. Extend the frame-owned store accepted in
PR #2357, rather than adding an
entity cache, callback bus or second lifetime system. This is a proposal, not an
implemented API. That page/store is absent at this checkout (5166fe4588);
its implementation is a prerequisite, not presumed landed.
Vanilla and NeoForge 26.3
Verified using /workspace/horizon: python3 tools/rea_vanilla.py method net.minecraft.client.renderer.entity.EntityRenderer extractRenderState.
Vanilla copies type, interpolated position, invisibility, age, dimensions,
name/leash data, fire, outline and packed light into state. The corresponding
callers query reports 25 references with complete coverage, including
createRenderState, living renderers and specialized boat, minecart, projectile,
display and other renderers. This establishes that the base extractor alone is
not the final specialized-state boundary. It does not establish a mod hook or
complete higher-level world/UI caller coverage.
The issue requests RegisterRenderStateModifiersEvent. The public
26.3 Javadoc
was inaccessible. Exact registration types, callback order, threading, field
coverage and limits are unverified. Obtain public documentation before
adopting the final counterpart contract. No NeoForge source, patches or other
project renderer source was read. The limits below are Horizon proposals.
Verified routes and preparation boundary
Owners relative to the repository at 5166fe4588; these are current integration
points, not existing extracted-state implementations.
| Route | File:line owner | Required treatment |
|---|---|---|
| Mob library rendering | crates/horizon-client/src/mobs/render.rs:1487, :1503 | Split extraction from render/render_with; consume prepared values only |
| Gameplay mob and remote-player geometry | crates/horizon-client/src/game/mobs/creatures/mod.rs:8016, :8134, :8215 | Prepare before replacement hooks, body, layers and outlines; never fire separately per layer |
| Own-player geometry | crates/horizon-client/src/game/interface/perspective.rs:786 | Use the same operation before player replacement and built-in posing |
| Existing replacement adapters | crates/horizon-client/src/game/modding/native_mob_draw.rs:53, :71 | Read the prepared snapshot; do not redispatch modifiers |
| Frame preparation and consumer | crates/horizon-client/src/game.rs:15048, :15720, :15040 | Collect prepared records into the existing frame packet before freeze and consumption |
| Frame owner | crates/horizon-client-render/src/gfx.rs:1104 | Extend #2357 ownership and leases, not a parallel store |
First decide the view and candidate using existing visibility policy. Capture a complete API-owned base plus supported specialized record; fire the matching modifiers with ClientHost; validate and apply atomically; then pose and emit commands exclusively from the resulting record. Do not modify a live Mob and restore it afterward. Final dispatch belongs in gameplay, with library helpers returning decisions/records and never reaching upward into Game.
World mobs, remote and own players must share this operation. Inventory/entity previews, spawner displays, first-person arms, projectiles, item/display entities, shadow views and screenshot/split-view entry paths require a full caller audit before implementation: the table is verified evidence for the named paths, not a claim those additional routes are covered. Recommend full-entity previews use view-scoped records; first-person arms remain a separate presentation unless public counterpart evidence requires them. Unsupported specialized schemas must be refused at registration, never silently skipped after accepting registration.
Proposed contract
register_render_state_modifiers is a client registration hook with ClientHost
and a typed registrar. Resolve entity types and supported state schemas once at
load into dense per-type subscriber lists. Dependency order applies identically
on native and wasm; within one mod use registration order. Register against a
supported state capability, not renderer implementation classes. Return named
UnsupportedEntityRenderStateCapability for unavailable fields or schemas,
InvalidEntityRenderStateSchema for invalid bounds, and an owned unknown-type
refusal only for this registrar's explicit type declarations.
Use API-owned EntityRenderStateWire records: fixed-width scalars, enums, vectors, resolved resource keys and bounded custom fields using #2357's Wire schema. No engine Mob, pointers, Any, GPU handles or mutable live references cross the adapter. Start with presentation fields such as tint, scale, outline and supported pose inputs. Identity, type and lifetime are immutable. A dependent listener sees the previous listener's validated result. Existing replacement hooks remain separate drawing moments and consume that same result without another modifier call. DrawContext exposes immutable snapshot reads; workers never call mods.
Add entity records as a namespace inside #2357's frame-owned store, keyed by view and the existing entity lifecycle holder. Never create a second entity generation. Preview records use frame-local occurrence identity, not fabricated persistent entity handles. Reuse frame/world/resource/target/mod epoch checks, freeze, deferred-consumer leases, backpressure and teardown from #2357. Values reset each frame. One entity in two views has two records; body, armor, outline and shadow consumers in the same prepared view share one record. Reload or lifetime change before apply refuses publication rather than recomputing.
Host writes and state apply follow decide/fire/apply in one existing transaction. Inside lifted passes retirement and journal emission wait for root commit; rollback publishes no record or journal. Saves are refused while an action savepoint is open. Preparation must not leak a record from an uncommitted pass. No additional transaction or identity mechanism belongs in this feature.
Untrusted input, bounds and cost
Inherit #2357's slot, byte, nesting and in-flight ceilings, charged to the same frame budget. Proposed additional custom data ceiling: 4 KiB/entity/mod and 256 custom fields, counted in the shared per-mod/frame budget. Do not cap the number of vanilla entities or truncate vanilla extracted fields to meet a mod budget: refuse the offending mod contribution and preserve the base record. Check encoded and decoded lengths with checked arithmetic before allocation.
Validate modified floats as finite; proposed scale range [0,16], tint channels [0,1], outline a u32 color, pose angles [-360,360] degrees. These restrictions apply to writes, not untouched vanilla values. Resource keys must match captured generations. Reject NaN, infinity, negative scale, excessive values and invalid tags atomically, retaining the previous valid record. A failed listener cannot undo earlier listeners. Native unwind, wasm trap or fuel exhaustion rolls back its contribution and Host writes, disables that listener for the mod generation, and emits one diagnostic. Native aborts cannot be recovered or callbacks safely preempted. Wasm receives bounded fuel; neither target runs arbitrary destructors on submission workers.
No listeners: one cached global enabled-bit branch and, when enabled elsewhere, one dense per-type empty check, O(1). Zero modifier calls, extension allocations, serialization or scans on an unmatched entity. Keep the existing baseline path when globally unused. With listeners, dispatch only matching registrations; reuse arenas after frame leases expire. These are acceptance requirements, not measured performance claims.
Acceptance matrix
Future implementation expectations, not results of runtime tests in this note.
| Case | Expected result |
|---|---|
| Pig modifier sets scale 2; cow base scale 1 | Pig draws at 2, cow at 1 |
| Own and remote player set tint red | Both draw red, including their armor/outline consumers where supported |
| Two dependent modifiers set scale 2 then multiply by 3 | Final scale 6; each called once per entity/view |
| Body plus 3 layers in one view | 1 modifier dispatch, 4 consumers of the same record |
| Same entity in two prepared views | 2 dispatches, isolated records |
| Frame N writes red, N+1 omits tint | N+1 uses base tint |
| NaN/infinity, scale -1 or 17, 4097 custom bytes | 0 offending changes; base/earlier valid state survives |
| Native unwind or wasm trap after Host write | 0 committed writes, 1 diagnostic, listener disabled |
| Entity retired/reused or resource epoch changes before apply | 0 stale publications |
| Lifted pass rolls back after preparation | 0 published records and journal entries |
| Save requested during open action savepoint | Save refused |
| Vanilla scene exceeds mod byte budget | All vanilla entities retained; offending mod contribution refused |
| Unsupported specialized schema | Registration returns UnsupportedEntityRenderStateCapability |
| Native and wasm receive identical Wire input | Identical state values and refusal results |
| No listeners across 100 frames | Baseline geometry identical; 0 modifier calls/allocations/scans |
Proposed stages (file none)
- Approve this boundary and verify public counterpart semantics; finish the vanilla and Horizon world/UI caller census.
- Implement #2357's shared frame store and snapshot-only draw boundary; add entity records within it and split all accepted routes into extraction and consumption.
- Add the registrar, catalog and generated native/wasm adapters together, then demonstrate a type-specific visible modification and run the matrix plus unused-path allocation measurements.
Open questions
- Exact NeoForge 26.3 contract is unverified. Recommend public Javadoc evidence before freezing signatures or claiming equivalence.
- Which specialized schemas and preview routes ship first? Recommend common/living/player schemas together, explicit refusal for others, and full-entity previews included; finish the route audit before accepting registrations.
- Are proposed custom-data budgets sufficient? Recommend the shared frame ceilings plus 4 KiB/entity/mod initially; measure real scenes without reducing vanilla capacity.
Design: general entity types
Provisional answer to #792: register a type once, store each instance in a game-owned general entity row, and dispatch its catalog-generated EntityPart behavior through a resolved type slot. The row owns identity, residency, persistence and tracking. It must never create a throwable or other template entity. This is a proposal for Ari's decision on the issue, not implemented API or a closure claim.
Evidence and limits
Vanilla 26.3 was queried from /workspace/horizon with python3 tools/rea_vanilla.py: method net.minecraft.server.level.ServerLevel tickNonPassenger runs commonTick, tick, then passenger ticking. --overload-index 0 method net.minecraft.world.entity.EntityType loadEntityRecursive delegates compound input through registry-aware TagValueInput and a problem reporter; this wrapper alone does not establish unknown-type recovery. callers net.minecraft.server.level.ServerLevel addFreshEntity reports 36 callers, including dispenser vehicles, loot drops, TNT, hatching and dragon creation. Creation is consequently wider than summon. Exact recursive-load internals, passenger tick semantics and global vanilla iteration ordering remain unverified; do not infer them from these queries.
The issue names NeoForge 26.3 RegisterEvent and Registries.ENTITY_TYPE. The permitted entity guide describes registered singleton types, instance behavior, separate renderers, server insertion and configurable tracking. It is explicitly labelled 26.1, not 26.3. The 26.3 RegisterEvent Javadoc was inaccessible through the web reader. Exact NeoForge 26.3 registration, lifecycle ordering, persistence and tracking contracts are unverified. No NeoForge source, patches or other project's implementation was read. The following is an explicit Horizon proposal, not asserted NeoForge equivalence.
Registration and ownership
Freeze a namespaced entity-type registry before worlds or connections open. A declaration supplies category, finite positive dimensions, save/summon policy, bounded tracking distance and update interval, codec schema and required client features. Reject duplicate keys, unresolved required dependencies and invalid limits at registration. Resolve registry keys to compact slots, behavior subscribers and codecs once. Content declarations and code declarations use one registry, with no reserved throwable fallback or compatibility alias.
Use one dense general-entity store per level, with constant-time identity-to-slot lookup and generation validation; removal may swap rows while updating the index. A row contains type slot, UUID, runtime network id, incarnation/join identity, dimension, pose, velocity, lifecycle flags and attachment holder. Never persist runtime slots, network ids or pointers. EntityRef validates world epoch, dimension and incarnation before exposing a row. Reusing id 42 cannot make an old handle address its replacement. UUID continuity on reload does not revive an old live handle.
EntityPart is shared behavior, not mutable per-instance storage. Follow PR #2328, the provisional #796 state design: existing attachments are the sole Part-data store, with typed authoring erased at registration, snapshot reads and checked transactional edits. Do not introduce a parallel entity data map. #779 supplies entity scope and scoped moments; it does not by itself supply entity ownership. Generate new methods only for named verified moments, with ServerHost or ClientHost and the existing dependency dispatch order. No Game references escape.
Tick and residency
At the authoritative entity phase, walk eligible general rows once in stable join order, with an indexed iteration schedule maintained on mutation rather than sorting each tick. For each root run common bookkeeping, its subscribed server behavior, then its passenger subtree. A passenger is not also ticked as a root. Newly committed spawns first tick next phase; a row removed before its turn receives no tick. Buffer structural edits until safe boundaries. Revalidate deferred operations after listener writes and refuse stale decisions without recomputing.
Join ordering and next-phase spawn visibility are proposed Horizon choices pending vanilla oracle comparison. They must not reorder existing vanilla families incidentally. Client interpolation/render updates never execute authoritative behavior.
Use #1606's residency journal and visibility boundary for insert, lift, restore, move and retirement. Unloaded or parked entities persist but do not tick; ordinary entities do not acquire chunk tickets merely by existing. Temporary lift is not removal and cannot retire attachments or emit a tracking despawn. Commit publishes changes once; abort restores the original join identity, row and attachments. Save refuses while a lifted pass or entity transaction is open. Dimension transfer preserves UUID and data, invalidates source-scoped handles, removes source tracking and creates destination tracking atomically. World close invalidates its epoch.
Save, load and missing mods
Save common fields plus namespaced type key, payload schema version and bounded attachment payloads to the existing entity-region owner. Serialize committed snapshots only. Load validates sizes, nesting, UUID uniqueness, passenger cycles and coordinates before insertion; decode and construct privately, then publish through the same join boundary as fresh spawn. A failed codec cannot leave a partial live row. noSave types produce zero persisted records.
For a missing type/mod or unsupported payload version, preserve the original bounded record as opaque quarantined data, with zero ticks, collisions or tracking. Report the unavailable type once per load summary. Save preserves that record without rewriting its payload; reinstalling the mod permits a later load. Never silently delete it or instantiate a vanilla substitute. Invalid or over-budget records produce an explicit recoverable load error and retain the original region backup; never truncate into a valid-looking entity. Migration is explicit schema decoding, not an old-API compatibility path.
Tracking and rendering handoff
Negotiate required type/schema/features before joining. A client missing a required entity implementation is refused with a clear reason before world state is sent. Map stable keys to connection-local protocol slots; server ids alone are insufficient. Interest entry sends one bounded spawn snapshot (identity, type, pose and explicitly synchronized fields); subsequent revisioned deltas follow declared update cadence. Late join receives current state, not replayed ticks. Interest exit, removal and dimension departure send one removal; reconnect gets a fresh snapshot. Ignore stale-incarnation deltas and request a bounded resnapshot after revision gaps.
Persistence does not imply synchronization: only declared fields cross the network. #813's shared payload transport should own attachment synchronization rather than adding a competing channel. Decode into client-owned tracked rows. Hand immutable render snapshots to a client type renderer through the existing drawing context; no server Part tick or mutable simulation access during drawing. Missing optional visual assets use a diagnostic visual; missing required behavior refuses connection. Resource reload disposes cached render resources without replacing entity identity.
The moving-structures carrier remains a game-owned specialized type and does not depend on general registration. Share identity, residency, tracking and save boundaries; its cell ownership, swept collision and assembly journal remain specialized. Never create two carrier tickers or serialize its cells twice.
Lifecycle route owners and migration obligations
Paths below are verified existing integration owners relative to this repository. They identify boundaries to extend, not proof that general types already traverse them.
| Route | Existing file:line owner; required treatment |
|---|---|
| Part registration and dispatch | crates/horizon-mod-vocab/src/parts/entities.rs:124 registers behavior; :53 resolves a type's Parts. Resolve into slots at load. |
| Mod spawning, including callers using level handles | crates/horizon-client/src/game/modding/native_level_spawning.rs:15 owns level_spawn_mob. Add a general insertion path without pretending every entity is a mob. |
| Natural/spawner policy | crates/horizon-client/src/game/mobs/natural_spawning.rs:43 gates eligible chunks. General MISC types do not gain natural spawning implicitly. Explicit spawn adapters use the same insertion boundary. |
| Identity, reads and queries | crates/horizon-client/src/game/modding/native_entity_refs.rs:101 resolves targets, :176 creates refs, :245 queries entities. Extend all three to general rows; do not scan families for each lookup. |
| Temporary lift | crates/horizon-client/src/game/modding/native_entity_refs.rs:86 owns held-out access. General rows must retain residency visibility and balance restoration after panic. |
| Residency insert/remove/unload/restore/world reset | crates/horizon-client/src/join_seq/residency.rs:95 journals events, :108 drains them, :121 resets epoch. Integrate the general owner here; retirement waits for root commit. |
| Save and chunk reload | crates/horizon-client/src/save.rs:3101 loads entity tags; :3316 and :3320 save synchronously/asynchronously. Both consume the same committed general snapshots, including quarantine. |
| Dimension/passenger transfer | crates/horizon-client/src/game/entities/vehicle_teleport.rs:231 transfers vehicles, :391 passengers. Validate the entire tree before moving any row. |
| Server tracking and ids | crates/horizon-client/src/game/network/lan_entities.rs:130 runs native tracking, :197 builds object states, :578 supplies UUIDs, :590 assigns ids. Include general rows once. |
Every creation source must reach the general insertion coordinator: summon, mod/fake-player spawn, item use, dispenser, projectile creation, breeding, natural and block spawners, conversion, chunk load and dimension arrival. Every exit must reach retirement: discard, death, despawn, conversion, chunk unload, dimension departure, disconnect-owned cleanup and world close. Client spawn/update/remove packets and renderer disposal require parallel coverage. Exact leaf owners for those sources, the authoritative general tick seam and client renderer/packet dispatch are not yet verified by this note; implementation must trace and record file:line callers before enabling them. Existing vanilla-only routes remain unchanged until explicitly migrated. This is a coverage gap, not an exhaustive verified caller inventory.
Trust, native/wasm and cost
Native and wasm use the same registry, dependencies, state model and generated contract. Native exact-build checks and wasm interface linking remain loader concerns. Existing native-only registration is not evidence of wasm parity; enable this feature only when both adapters implement registration, tick, codecs and client handoff.
Treat registration, callback results, serialized input and packets as untrusted. Reject NaN/infinite positions or dimensions, negative/overflowed lengths, unknown owned type ids, duplicate UUIDs and stale handles before writes. Unrelated unknown ids retain their existing behavior. Propose payload limit 64 KiB per entity, nesting 32, passenger depth 16, positive dimensions at most 64 blocks, tracking at most 32 chunks and update interval 1–1200 ticks; confirm these provisional limits before implementation. Bound aggregate decoding and outgoing bytes per connection too.
Catch native unwinding and wasm traps at dispatch: abort the callback transaction, discard buffered effects, restore lifts, disable the failing subscriber and report it. A native process-aborting panic cannot be recovered. No irreversible external I/O inside transactional callbacks. A subsequent built-in action must still succeed.
No per-entity mod list, type-name lookup, serialized-data read or family scan. Indexed type dispatch costs O(1) plus actual subscribers; identity lookup O(1); one O(n) eligible-row walk is the necessary tick work. No subscribers means zero hook payload allocation and zero mod calls. Maintain interest/spatial indexes on movement and insertion, and encode only dirty tracked fields. Measure actual callee costs before claiming this bound; current string-based Part lookup alone does not prove it.
Acceptance matrix
Future implementation tests, not results of this documentation change:
| Fixture | Concrete expected result |
|---|---|
| Register test:counter, spawn at (0,64,0), counter=40 | One general row, zero throwable rows; one server tick produces 41. |
| Two instances initially 40 and 7 | One tick gives 41 and 8; no shared mutable Part state. |
| Parent and passenger, each counter=0 | One phase gives 1 and 1; callback trace parent then passenger. |
| Callback spawns B during A's tick | B counter=0 at phase end, 1 after next phase. |
| Save at 41, reload, late client joins | Same UUID, counter=41, one client entity, zero template entities; first resumed tick gives 42. Old handle refuses. |
| Missing mod, save again, reinstall | Zero live rows/ticks/packets while absent; payload bytes preserved; reinstall restores counter=41. |
| Park for 10 ticks then resume | Counter stays 41 while parked, becomes 42 on first eligible tick. |
| Lift/restore and outer abort after tentative removal | Zero despawns, original join identity and counter=41 restored. Committed removal emits one despawn and retires holder once. |
| Transfer dimension with one passenger | Zero source rows, two destination rows, UUIDs unchanged; source handles refuse; one destination tick each. |
| Reuse runtime id 42 after removal | Old ref and old delta cannot affect replacement; replacement counter remains 7. |
| Join/reconnect/interest exit | One spawn snapshot per entry, one removal per exit, no server behavior on client. |
| Duplicate type, NaN pose, 65537-byte payload, passenger cycle | Refused with zero published rows/effects; next valid built-in step succeeds. |
| Native panic / wasm trap during tick | Counter and listener edits rolled back, balanced lift, zero outbound deltas; next valid built-in step succeeds. |
| No Parts on 1000 general entities | 1000 common ticks, zero callback calls and zero callback payload allocations; no per-entity scans. |
Run the scene on integrated, LAN and dedicated servers for both targets. Pin ordinary entity ordering and lifecycle behavior against vanilla oracles separately from Horizon-specific expected values.
Proposed stages (file none)
- Complete caller inventory, vanilla load/tick oracles and permitted 26.3 public-contract evidence; obtain Ari's decision on the issue.
- Add frozen type registry, bounded codecs, general row/index and attachment identity; test duplicate keys and stale refs.
- Integrate transactional insertion, retirement, residency, passenger transfer and committed save/quarantine; test rollback and recovery.
- Generate behavior and both target adapters; integrate authoritative scheduling with explicit ordering tests.
- Negotiate tracking, snapshot/delta transport and renderer handoff; test late join, reload and malformed packets.
- Enable the acceptance scene only after all routes are audited and hot-path allocation/index measurements pass. No Rust or API changes belong to this note.
Open questions
- Exact NeoForge 26.3 contract remains unavailable. Recommendation: obtain its public Javadoc before claiming counterpart parity; ask Ari to approve explicit Horizon choices where public documentation is silent.
- Must general ticks share one order with all vanilla families immediately? Recommendation: maintain stable general join order initially and require oracle evidence before migrating existing family scheduling.
- Missing-mod recovery versus fail-open world load? Recommendation: quarantine losslessly with visible diagnostics; malformed/oversized records require explicit recovery, never silent deletion.
- Which exact bounds are acceptable? Recommendation: start with the provisional limits above, measure valid workloads and agree aggregate world/connection quotas before enablement.
- Which EntityPart moments ship first? Recommendation: authoritative tick plus required lifecycle/codec integration only after matching existing named moments; do not invent parallel join/remove hooks. Coordinate with #779 and #796 before API generation.
- How does a vanilla client connect? Recommendation: refuse when required general types exist; approve a separate explicit protocol decision before advertising any vanilla-client representation.
Design: inside-block contact shapes
Provisional answer to #1011: share the sweep decision, with an API-owned shape value and a restricted, synchronous level/entity view. This is not an implemented API. No Rust changes are proposed for immediate landing; agreement and oracle fixtures precede code.
Vanilla and evidence
Vanilla 26.3 Entity.checkInsideBlocks(Vec3, Vec3, collector, visited, limit)
was inspected through tools/rea_vanilla.py --overload-index 1 method net.minecraft.world.entity.Entity checkInsideBlocks using the vanilla inspection tool.
It skips air, queries getEntityInsideCollisionShape, evaluates block and fluid
intersection independently, then inserts the position into visited only if either
intersects. A newly visited position applies block effects first (only when block
intersection succeeds), then fluid effects (only when fluid intersection succeeds).
The canonical full-block shape has a special inclusion shortcut. Precision uses
movement distance and the deflated target box; it is not the custom shape overlap.
Preserve that distinction, iteration budgets, ordering, and movement segmentation.
A fluid-only hit must not call the block's entity_inside callback.
The issue names no NeoForge counterpart: this is a vanilla block method, not a new event. Public 26.3 Block javadoc was inaccessible during this investigation. NeoForge extensions, limits and exact 26.3 signatures remain unverified; require public documentation before adopting any such contract. No NeoForge source, patches or other project's source was read.
Current owners and proposed shared boundary
References verified at worktree HEAD 1d1de8d42b; client paths below are relative
to crates/horizon-client/src/. These are current owners, not existing hook sites.
| Route | Owner | Required integration |
|---|---|---|
| Player movement | motion.rs:1697, motion.rs:1806, visitation motion.rs:1912 | Replace local shape special cases with the shared decision; preserve built-in effects |
| Mob AI contact | mobs/ai.rs:904, mobs/ai.rs:3314 | Route both contact traversals through the same accepted contact trace; avoid duplicate effects |
| Arrows | arrows.rs:1253, arrows.rs:1287, visitation arrows.rs:1321 | Preserve collector step order and independent fluid processing |
| Dropped items | entities.rs:960, ItemEntity::environment_tick and ItemEntity::stuck_multiplier | Replace subscription-only admission and separate block-effect scans with shared geometry |
| Other projectiles / game entity batches | game/mobs/creatures/mod.rs:205 | Same sweep adapter, bound to the actual entity |
| Generic sweep | block_contact.rs:240, visitation block_contact.rs:257 | Shared geometry decision before recording contacts |
| Existing server callback | game/native_hooks/entity_inside.rs:8; catalog crates/horizon-mod-vocab/src/hooks/entity_inside.rs:3 | Retain existing notification moment; do not use it as a shape filter |
Lower geometry prepares one candidate at a time in existing traversal order. The game adapter fires the Part query with typed HostSide, then lower geometry applies the validated shape and returns independent block/fluid bits. A common restricted context must work while mob/entity collections are lifted: immutable block/fluid state reads without chunk loads, position, entity bounds and existing lifecycle entity/level handles. Unavailable reads return an explicit unavailable result, never synthetic air. No mutable level borrow escapes into Parts. Integrated, LAN and dedicated simulation use ServerHost for the acting entity; client prediction uses ClientHost and cannot perform authoritative writes. Remote interpolation is not an authoritative contact sweep. Predict only with matching negotiated rules and rollback-owned state; otherwise defer custom contact effects to the server. Implementation must verify each adapter above, including all projectile kinds, rather than assume generic coverage from its name.
Proposed Part and shape contract
The three capability choices are provisional decisions recorded in the maintainer decision comment.
Declare get_entity_inside_collision_shape beside entity_inside, one Part
method for this moment. Query synchronously before contact admission, independently
of whether entity_inside has subscribers. Existing block ownership resolves the
provider; do not introduce listener voting or a second contact engine.
Return API-owned Wire variants Default, Empty, FullBlock, or Boxes containing
owned block-local f64 AABBs. Default uses vanilla's complete shape semantics,
including powder snow, cauldrons, end portals and tripwire. FullBlock deliberately
selects vanilla's canonical-full-block shortcut; Boxes containing the unit cube
uses geometric testing. Document this distinction rather than infer shape identity
from equivalent coordinates. Add oracle fixtures comparing both against vanilla
before any code. The fluid shape is not replaced by this method.
Initial negotiated custom-shape capability: at most 256 boxes, finite coordinates in [-16,16],
strict min < max on each axis. Negative coordinates are valid; negative extents,
NaN, infinity and oversized arrays are invalid. These are provisional capability
limits, never limits on vanilla Default shapes or vanilla traversal capacity.
Validate length before allocation/decoding and reject the entire invalid result,
retaining Default and issuing a bounded diagnostic. Expose the negotiated budget
at registration; refuse unsupported requested capabilities with named
UnsupportedInsideContactShapeCapability, equally for native and wasm. Do not
silently approximate an unsupported voxel operation. Use explicit shared #[derive(Wire)] on API shape types; generate only native/wasm
call tables and adapters from the catalog; no engine VoxelShape or borrowed game pointer crosses it.
A per-state resolved provider bit makes unused dispatch O(1): no registry scan, serialization, snapshots or extra allocation when no override exists. Existing vanilla geometry/effect work remains. Subscription checks may skip notifications, not fluid processing or built-in effects. Bound custom decoding and intersection work by the advertised box count, without reducing vanilla's own capacity.
Candidate discovery preserves vanilla traversal: only visited cells are queried. Out-of-cell coordinates do not expand discovery. A shape extending from (0,0,0) into x = [-1,0] cannot admit an entity wholly in the neighboring cell unless the origin cell is independently visited. This matches vanilla BlockGetter stationary cell enumeration and the current geometry sweep; it is not a spatial shape index.
Before implementation, review a concrete query signature with typed HostSide, an object-safe scoped read view, captured acting-entity facts and structured refusal variants. The provisional read surface is immutable loaded block and fluid reads plus captured facts of the acting entity, through a scoped object-safe read capability. No mutation during selection. Under a lifted pass, an unavailable read is an explicit refusal, never inferred as "the entity is gone".
Bound total custom work as well as each response: 256 boxes contain 12,288 coordinate bytes before tags and lengths per candidate. Define aggregate read/query accounting and exhaustion behavior before implementation. Before the numbers freeze, the implementation adds aggregate per-tick read/query accounting and measures the real wasm crossing cost. If the measurement says the numbers are wrong, they change then. Preserve vanilla traversal capacity and the unused fast path.
Lifecycle, transactions and failure
Use existing lifecycle holders for entity and level identity; no second generation counter. A candidate captures entity identity, movement segment, state/position, and relevant world revision. Apply rechecks them in the same transaction as query writes and refuses a stale decision, rolling back writes; never recompute after a callback mutates the environment. Prefer a read-only query capability so shape selection cannot mutate its own preconditions. Any permitted attachment writes must remain rollback-owned. During lifted passes defer retirement and external effect journal emission to root commit; no notification or packet escapes an aborted candidate. Refuse saves during an open action savepoint through the existing save coordinator, not a second save service.
Native unwind or wasm trap discards that invocation's output and writes, selects Default and disables the faulty provider for the session with a bounded diagnostic. Native abort is unrecoverable. Both targets use identical validation and recovery semantics. Existing entity_inside notification remains at its documented batch boundary; geometry acceptance and effect ordering are captured in the sweep trace, not reconstructed from a later callback against a changed block.
Baseline and acceptance matrix
Issue #1011 requires unchanged no-override contacts and ordering. That is the
required baseline for this feature. Current player motion explicitly skips tripwire
and end portals (motion.rs, check_inside_blocks); vanilla queries their shapes.
Thus exact vanilla parity cannot simultaneously describe the existing baseline.
Capture current per-route traces first; introduce any vanilla parity correction
separately with maintainer agreement before rebasing those fixtures. The proposed
complete vanilla Default semantics remain a target, not an approved baseline change.
“Block then fluid” below means callback/collection order. The collector subsequently orders effects by kind within each step; do not equate collection order with final effect execution order.
Implementation requirements, not runtime results obtained for this note. Shapes and boxes below use block-local coordinates for a stationary candidate at (0,0,0). Run each on player, mob, arrow, item and every projectile adapter, native and wasm.
| Fixture | Expected result |
|---|---|
| Empty, no fluid | visited 0; block callbacks 0; fluid effects 0 |
| FullBlock, centered entity box [.25,.75] on all axes | visited 1; block callbacks 1 |
| Boxes unit cube, centered box | visited 1; block callbacks 1 |
| FullBlock versus Boxes unit cube, vanilla oracle fixtures before any code | compare both against vanilla; preserve canonical identity shortcut versus geometric testing |
| Shape selection reads under a lifted pass | immutable loaded block/fluid reads and captured acting-entity facts only through scoped object-safe capability; unavailable read explicitly refused; no mutation |
| Initial negotiated 256-box / [-16,16] capability | aggregate per-tick read/query accounting and real wasm crossing cost measured before numbers freeze; revise numbers if measurement requires; no vanilla ceiling |
| Boxes [0,0,0]–[1,.5,1], entity y [.6,.9] | visited 0; block callbacks 0 |
| Same shape, entity y [.25,.4] | visited 1; block callbacks 1 |
| Empty block shape, intersecting fluid | visited 1; block callbacks 0; fluid effects 1 |
| Both intersect, candidate repeated twice | visited 1; trace block then fluid, each once |
| Neither intersects on first segment, fluid intersects on second | first segment does not mark visited; second produces fluid once |
| 257 boxes, NaN, infinity, reversed or zero extents | whole result rejected; Default result equals baseline |
| Provider traps after attachment write | attachment unchanged; Default contact; provider disabled |
| Entity retired or state/revision changes before apply | stale decision refused; zero committed effects |
| Root abort during lifted pass | no retirement or journal emission; save request refused while savepoint open |
| Default / no override | unchanged captured Horizon contacts and order on every route; separately record vanilla oracle differences across short/long moves and special blocks |
| Empty on dropped-item fire/cactus/lava-cauldron/cobweb/powder-snow contact | applicable block damage, ignition and slowing suppressed; independent fluid effects and existing burning timers preserved |
| Origin block shape x [-1,0], entity wholly in neighboring cell, stationary | origin not queried; no origin block callback or effect |
| Override with no entity_inside listeners | geometry still controls built-in block effects; fluid unaffected |
Proposed stages
File none for implementation at this stage. First agree representation, restricted context and failure semantics; obtain vanilla oracle traces for special shapes, fluid-only contact and the full-block shortcut. Then implement the shared sweep with unchanged default-only behavior across every route; resolve recorded vanilla differences separately. Finally add the catalog Part query, generated native/wasm transport, registration refusal, docs, sample and behavioral fixtures together. No player-only intermediate public API should ship.
Design: level extracted state
Provisional answer to #866:
use a typed, frame-owned extension store, freeze it after base extraction and
extract_level_render_state, and lend immutable values to drawing through a
snapshot-only DrawContext. This is a design proposal, not an implemented API
or a claim that the issue is complete.
Vanilla and NeoForge 26.3
Verified with the pinned vanilla oracle from /workspace/horizon:
python3 tools/rea_vanilla.py class net.minecraft.client.renderer.LevelRenderer
and python3 tools/rea_vanilla.py method net.minecraft.client.renderer.LevelRenderer render.
render passes levelRenderState to submitFeatures, then calls
FeatureRenderDispatcher.prepareFrame before constructing the frame graph.
This verifies a state-to-submission boundary, not arbitrary mod storage or a
particular extraction thread. A query for extractLevel failed because that
method is absent; the complete extraction caller/thread census remains open.
Vanilla has no mod registration contract.
The issue names NeoForge 26.3 ExtractLevelRenderStateEvent as the counterpart.
The public 26.3 Javadoc
was inaccessible during this investigation. Its exact fields, cancellation,
thread guarantees, ordering and attachment limits are unverified. The issue's
description is intent, not verification of NeoForge behavior. Require reachable
public documentation before adopting the counterpart's full contract; do not
infer it from older versions, source or patches. All limits below are proposed
Horizon limits, not NeoForge limits.
Verified Horizon owners and missing seam
Paths below are relative to the repository at 731b0cae68.
| Owner | Evidence | Implication |
|---|---|---|
| Frame preparation | crates/horizon-client/src/game.rs:15048, render_with | Builds frame before consumer; proposed extraction/freeze integration belongs here |
| Renderer consumer | crates/horizon-client/src/game.rs:15040, render_frame | Calls renderer with prepared frame |
| Deferred submission | crates/horizon-client/src/game.rs:15029 | Submit thread may retain a frame after presentation |
| Frame fields | crates/horizon-client-render/src/gfx.rs:1104, Frame | Existing borrowed frame is not proof of owned extension storage |
| Renderer entry and resize | crates/horizon-client-render/src/gfx.rs:8024, :5640 | Submission and target replacement need explicit epoch checks |
| Renderer replacement | crates/horizon-client/src/game.rs:14236, adopt_renderer | Rebuild must retire old extension/resource generations |
| Fog extraction | crates/horizon-client/src/game/interface/render_tick.rs:133 | Existing base preparation must precede extension freeze |
| Block-screen visibility | crates/horizon-client/src/game/interface/render_tick.rs:529 | Keep existing extraction/cancellation decisions; do not rerun in draw |
| Draw adapter | crates/horizon-client/src/game/host/draw.rs:6 | Currently contains &Game; cannot certify snapshot-only access |
This is a verified integration census, not a complete proof of every submission,
reload or early-return route. Implementation must audit normal world rendering,
screenshot sequences, split views, loading/title frames, device loss and deferred
submission. Early loading returns inside render_with must not fire a world hook.
Proposed contract
Register LevelStateKey<T> at mod load, resolve it to a dense slot and retain
schema and mod-generation identity. T must use an API-owned LevelStateWire
schema: fixed-width scalars, bounded sequences/strings, API enums, vectors and
resolved resource keys. The API owns schema validation and generated native/wasm
encoding. No Any, Rust pointers, engine structs, closures, GPU handles or live
entity/world references cross this boundary. Custom records compose supported
Wire fields; a type parameter is not permission to serialize arbitrary memory.
Registration returns the named refusal UnsupportedLevelStateCapability for
unsupported field kinds, live references, custom destructors or requested mutable
draw access; duplicate keys return DuplicateLevelStateKey, invalid bounds return
InvalidLevelStateSchema. Refuse before enabling the listener. These refusals
apply only to this registrar's declarations, not unrelated unknown identifiers.
On the client owner thread: capture base camera, fog and existing visibility
results; decide the world/view and epoch; fire one dependency-ordered
extract_level_render_state with ClientHost and a mutable typed frame writer;
validate and atomically publish each listener's contribution; freeze; submit.
Host writes and apply use the existing transaction discipline. Recheck world,
resource and mod generations after callbacks; refuse stale publication without
recomputing or firing twice. Extraction receives no DrawContext.
The frame packet owns the frozen store, identified by frame sequence, view,
world lifetime, resource generation, target generation and mod generation.
Values default to absent every frame; no inheritance from frame N to N+1.
A listener may write only its registered slots; declared dependent readers may
read those slots. Missing values return None. Later listeners see only committed
prior contributions. No draw callback mutates the store.
Drawing receives a frame-scoped read view through DrawContext and reads
state(key) -> Option<&T> (equivalent checked decoded view on wasm). The context
exposes captured camera/resources and approved draw commands, never a route to
mutable live game state. Existing adapters holding Game require refactoring
before this promise can ship. Draw callbacks run during command preparation;
submission workers consume immutable commands and leases, and never call mods
or carry a ClientHost. This permits separate extraction/submission threads
without requiring native or wasm callbacks to be worker-safe.
A frame and every deferred consumer share an immutable lease. Release only after the final CPU consumer completes; GPU-referenced resources retain their existing fence lifetime separately. Never clear or reuse storage at flip/present alone. Bound in-flight packets to 3; backpressure before extraction when all are leased. Suspended/zero-size/no-world frames create no level store and fire no level hook. Failed or cancelled frames release their lease and cannot publish commands.
Resize increments target generation: refuse old target-dependent submission and extract afresh; never patch frozen dimensions. Resource reload and dimension switch increment their respective generations, retire old packets and prevent new publication from them. Existing accepted GPU work may finish with leased old resources. Teardown stops callbacks, drains/cancels CPU submissions, waits for resource users and then releases stores before unloading native code/wasm instances.
Untrusted input and unused cost
Proposed ceilings: 256 slots per mod, 64 KiB encoded and decoded per slot, 1 MiB per mod/frame, 8 MiB total/frame, sequence length 4096, nesting depth 8. Use checked arithmetic and validate lengths before allocating. Floats must be finite; camera-relative positions have magnitude at most 1,000,000, alpha is [0,1], and resource keys must match the captured generation. Reject invalid tags, NaN, infinity, stale keys and oversized values rather than clamp silently.
Invalid output rolls back that listener's entire contribution; built-in state and earlier valid contributions survive. Catch native unwinds at the adapter; wasm traps/fuel exhaustion have the same policy. Disable the faulty listener for its mod generation and emit one diagnostic. Native process aborts are not recoverable; native callbacks cannot be safely forcibly preempted. Wasm has a finite callback fuel budget. No arbitrary mod destructor executes on submission.
Resolve subscriptions and slots at registration. With no extraction listeners and no state consumers, one cached enabled-bit branch selects the existing path: 0 extension allocations, 0 store clears, 0 serialization, 0 callbacks and 0 scans per frame. With listeners, reuse bounded arenas only after leases expire; dispatch only subscribed listeners. Verify allocation counters and benchmark the callee path before claiming a measured performance result.
Acceptance matrix
Future implementation tests; these are expected values, not passing results.
| Scenario | Concrete expected result |
|---|---|
| Extract A=7; live value changes to B=9 before draw | N draws 7; N+1 extracts/draws 9 |
| N writes 7; listener omits slot in N+1 | N+1 reads None |
| Two views, contributions 3 and 5 | Each draws its own value, never the other's |
| Listener order dependency A then B | B reads A's committed 7 exactly once |
| Base overlay cancelled before level hook | 0 built-in overlay draws after freeze |
| NaN, infinity, 65537-byte slot, 4097 elements | 0 offending listener values; built-in state preserved |
| Native unwind / wasm trap after writing 7 | Slot absent; 1 diagnostic; 0 later callbacks that generation |
| Reload epoch 4 to 5 before publication | 0 epoch-4 publications into epoch 5 |
| Resize 800x600 to 1024x768 | New snapshot reports 1024x768; 0 old-target submissions |
| No world / zero-size surface | 0 level callbacks and stores |
| Three retained packets; request fourth | 3 live stores; fourth waits before extraction |
| Present N while deferred consumer holds N | N still reads 7; arena reuse count 0 until release |
| Same Wire input native/wasm | Same slot values, refusal and ordering results |
| No subscriptions for 100 frames | 0 extension allocations, scans and callbacks |
Proposed stages
Implementation file: none in this change. First approve ownership, Wire schema, limits and public counterpart evidence. Next audit and refactor the complete frame/draw boundary, with lifetime and early-return tests. Then add the catalog hook, generated native/wasm adapters and typed storage together. Finally wire a sample contribution into a real drawing consumer and run the matrix plus empty path allocation and throughput measurements. No Rust API is added by this note.
Open questions
- Exact NeoForge fields, ordering, cancellation and limits are unverified. Recommend requiring public 26.3 Javadoc before final hook adoption; keep the proposed store as Horizon design, not a claim of counterpart equivalence.
- Complete extraction/caller and async owner census remains unfinished. Recommend proving all world/view entry and early-return routes before coding dispatch, using vanilla oracle callers and Horizon consumer tests.
- How broad should snapshot-only DrawContext migration be? Recommend migrating every consumer of this store and refusing registrations requiring live reads; do not expose the store through the current Game-backed adapter unchanged.
- Are budgets sufficient for real mods? Recommend the explicit ceilings above initially, measure representative native/wasm scenes, and change them only through reviewed schema policy. Do not offer an unbounded native escape hatch.
Design: main target configuration
Provisional answer to #869:
configure one renderer-owned main colour attachment and mandatory depth attachment,
before allocation, through configure_main_render_target with ClientHost.
The initial supported change proposed here is adding stencil through a typed
depth_stencil choice. This requires renderer work; it is not available today.
The colour format is never offered. This is a review proposal, not an adopted
claim of full NeoForge parity.
Evidence and limits
Vanilla 26.3 was inspected only through tools/rea_vanilla.py from
/workspace/horizon: class MainTarget (fully qualified
com.mojang.blaze3d.pipeline.MainTarget), and methods allocateAttachments,
allocateColorAttachment, allocateDepthAttachment, plus callers of
createFrameBuffer. Allocation asserts the render thread, tries dimensions with
fallback, and requires both colour and depth before creating their views.
Colour allocation uses the target's colour format, one mip and one layer;
allocation failure returns null for fallback. These are method owners, not
invented Java file:line references. No Mojang files are included here.
NeoForge 26.3's issue-named counterpart is ConfigureMainRenderTargetEvent.
Its exact fields, defaults, mutability, bus and recreation timing remain
unverified: the public 26.3 javadoc endpoint
was inaccessible on 2026-11-09. No NeoForge source or patches were consulted.
Do not adopt this narrower field set as its full contract: public documentation
of each field and timing is a prerequisite to approving implementation.
Verified Horizon owners at this revision (all paths relative to the repository):
crates/horizon-client-render/src/gfx.rs:2911: renderer construction;:3082: frame configuration selection before pipeline construction.gfx.rs:5515: offscreen target helper, single sample, one mip/layer, renderer-selected format and usage.gfx.rs:5582: depth helper,Depth32Float, single sample, render, sample and copy-source usage; depth consumers must survive any proposed change.gfx.rs:5640: resize validates against device maximum, suspends invalid sizes, then configures surface or recreates offscreen target and recreates depth.gfx.rs:5547: surface restoration;:2651: device-loss callback;:2354: frame configuration policy. Surface configuration alone is not proof that all dependent targets and pipelines have been rebuilt.
Proposed fields and refusals
| Field | Supported values | Ownership |
|---|---|---|
depth_stencil | Depth, DepthStencil | Writable; default Depth; stencil default clear 0 |
| extent | Current drawable width and height | Read-only renderer decision |
| colour attachments | Exactly one main colour | Renderer-owned |
| samples, mips, layers | 1, 1, 1 | Renderer-owned |
| depth presence | Always true | Renderer-owned |
| colour format, primaries, transfer, alpha | No writable field | Renderer-owned |
| depth format and usage bits | No raw format or flags | Renderer chooses a supported compatible format |
Refuse extra colour attachments, MSAA, depth removal, custom dimensions,
custom usages, raw GPU objects, clear-colour overrides and colour/alpha policy
requests as UnsupportedMainTargetCapability at registration, identifying the
mod and requested capability. A required unsupported capability prevents that
registration; never silently ignore it. This refusal owns only this feature's
capability identifiers. Unknown identifiers elsewhere retain existing behaviour.
Stencil is advertised only when the renderer can support it throughout all
passes, depth sampling and copies; otherwise its registration is refused too.
No arbitrary fixed dimension ceiling: preserve renderer/device capacity and
vanilla's requested-size/fallback behaviour rather than imposing a mod-only cap.
The API owns MainTargetConfigWire, MainTargetDepthStencilWire and
MainTargetCapabilityWire with explicit discriminants and checked decoding;
no wgpu types or Rust memory layouts cross the wasm boundary. Native and wasm
use the same configuration, capability negotiation, dependency-ordered listener
sequence and validation. Unknown discriminants, oversized payloads and malformed
values are refused before callbacks or allocation. There are no floating point
mod fields; NaN/infinity cannot enter through a numeric escape hatch.
One recreation boundary
Client-lib decides a candidate generation with extent, device capabilities,
colour policy and reload generation. The game dispatches with ClientHost,
then applies the validated result in the same transaction, rechecking those
preconditions without recomputing the decision. Each listener sees the preceding
validated result; the last valid write wins. Invalid output refuses the whole
candidate. Catch a callback unwind or wasm trap at the runtime boundary, discard
its transaction and refuse the candidate with mod attribution. A native process
abort cannot be contained. Do not resume allocation using partially edited state.
Validate the whole attachment/pipeline combination before GPU allocation, then build a replacement generation and atomically publish it with matching views, bind groups, generated colour boundaries and pipelines. Allocation failure drops the candidate and retains the old usable generation; first creation fails with a diagnostic. Device loss may make retention impossible: suspend rendering.
Use this boundary for initial window and offscreen creation, valid resize, restoration that replaces attachments, SDR/HDR changes, float-frame selection, shader-pack/resource reload affecting target consumers, and device recovery. Pure surface reacquisition or present-mode changes reuse the current decision if no attachment dependency changes. Zero-sized/minimized or over-device-limit windows suspend without invoking listeners or allocating; restoration makes one fresh decision for the valid size. Reload rebuilds resources and generated entry/exit conversions together. Mods receive no persistent GPU references. The colour rule remains linear light, white 1.0, values above 1 allowed, with renderer-owned frame format and alpha.
No-listener dispatch is an O(1) subscriber-presence check and uses existing renderer defaults without allocating a Wire message or scanning mods. It adds no per-frame callback, allocation or capability scan. Cache validated capabilities per device generation; recreation alone runs subscribed callbacks.
Acceptance matrix (implementation gates)
| Case | Concrete expected result |
|---|---|
| No listener, 1280 × 720 | 1 colour, depth present, samples/mips/layers 1/1/1; existing formats unchanged |
| Supported stencil request | DepthStencil, clear stencil 0, depth sampling/copy consumers valid |
| Resize to 1920 × 1080 | One new decision; stencil retained by listener; all views use 1920 × 1080 |
| Resize to 0 × 0 | Zero callbacks, zero allocations; rendering suspended |
| Maximum supported extent | Accepted if renderer can allocate; no extra mod ceiling |
| Extra colour or samples 4 request | UnsupportedMainTargetCapability at registration, zero callbacks |
| Unsupported stencil device | Same named registration refusal, no partial allocation |
| Invalid Wire tag / callback panic / wasm trap | Candidate refused; no publication or partial Host writes |
| SDR → HDR → float change | New generation each effective policy change; shader white stays 1.0, bright input 2.0 stays linear 2.0 at shader boundary |
| Resource reload / device recovery | Fresh capability validation as needed, no stale views or pipelines |
| Allocation failure during resize | Candidate dropped; old generation retained if usable |
| Native versus wasm | Identical accepted settings, order, refusals and resulting descriptor values |
Proposed stages
File: none for implementation in this change. Stage 1: obtain public NeoForge 26.3 field/timing documentation and approve the supported/refused table. Stage 2: implement the API-owned Wire schema and capability registration, then the shared decide/fire/apply boundary across every route above. Stage 3: prove the matrix with descriptor tests, failure injection and native/wasm fixtures; demonstrate stencil changing an observable render and surviving resize in game. This docs-only change provides no such implementation proof.
Open questions
- Does NeoForge expose more mandatory fields or different timing? Recommendation: require public 26.3 documentation before adopting the contract; reconcile all fields explicitly rather than claiming parity from the issue summary.
- Can stencil coexist with every current depth-copy consumer? Recommendation: gate capability advertisement on end-to-end validation; do not advertise it merely because the device supports a stencil format.
- Should resource reload always invoke the hook? Recommendation: invoke once when rebuilding attachment consumers; reuse the generation for unrelated reloads.
- How should conflicting listeners be diagnosed? Recommendation: dependency order, last validated write wins, with mod-attributed diagnostics for rejected candidates.
Design: mesh visibility
Provisional answer to #772: freeze section-wide and per-cell face decisions before worker dispatch, expose raw immutable section input, and invalidate by captured revisions and epochs. Ari's side posts the decision on the issue; this note implements no Rust API. It extends the section meshing plan for #1508 and shares its input, budgets and publication boundary with #864, rather than introducing another meshing context or worker event bus.
Vanilla and NeoForge 26.3
Verified vanilla using python3 tools/rea_vanilla.py method net.minecraft.client.renderer.chunk.SectionCompiler compile and callers for
that method from /workspace/horizon. Compile visits 4096 cells, marks solid
cells in VisGraph, collects block entities, emits fluids separately from block
models, sorts translucent output and resolves section connectivity. The sole
reported caller is SectionRenderDispatcher.RenderSection.CompileTask.doTask.
These are separate geometry and connectivity decisions; hiding geometry must
also remove its occlusion contribution to avoid invisible walls hiding terrain.
The issue explicitly identifies no NeoForge counterpart. Exact 26.3 absence cannot be independently proved from the available public references. The public model documentation is labelled 26.1 and describes directional culling and model data, not this contract. The attempted 26.3 event Javadoc was inaccessible; exact 26.3 behavior remains unverified. No NeoForge source, patches or other project's source informed this proposal.
Proposed contract
Keep the existing state visibility registration as the shared default recipe; add section and face inputs at the same preparation moment, with one canonical visibility plan. Final public naming awaits approval; no parallel legacy hook or compatibility alias. Resolve subscriptions to dense state slots at load. Client-lib captures input, game dispatches with ClientHost on the client world-owning thread, then validates and applies in one transaction. Refuse changed preconditions and reschedule; never recompute inside apply. Workers consume concrete frozen arrays only, with no new mod dispatch, live Host or world access. #1508's explicitly declared worker-safe geometry/tint capability is separate and does not authorize visibility callbacks on workers.
A section decision is Keep or HideTerrain. HideTerrain emits zero block-model, fluid and #864 terrain-contribution quads and contributes no occlusion. It does not suppress independently drawn block entities, entities or collision. Keep permits per-position decisions: block-model visibility, fluid visibility and a six-bit directional face mask, ordered down, up, north, south, west, east. Default mask is 63. Suppression composes by intersection in dependency order; no listener can restore another listener's hidden geometry. Section suppression wins. Unculled decorative quads follow the position's model visibility, not an invented directional classification. #864 unassociated section contributions follow section visibility; position-associated quads follow their position and declared cull face. Replacement and append retain #1508's conflict rules.
Face bits filter eligible geometry after normal culling; they do not force normally occluded faces to appear. A wholly hidden cell is nonoccluding, as in the existing hidden-state behavior. A partially masked cell retains full-face occlusion only on retained, explicitly full occluding faces. Recompute section connectivity conservatively from effective occlusion; uncertainty must allow visibility rather than incorrectly cull terrain. Translucent faces remain in the translucent sorting path and never become opaque occluders. Fluid masks filter emitted fluid directions without changing the captured fluid simulation.
Raw means API-owned state ids and immutable captured values before visibility substitution, not palette internals, mutable chunks, pointers or GPU buffers. Reuse #1508's 18 cubed snapshot: 5832 cells including edges and corners, with light, biome, availability, section coordinates and deterministic seeds. Reads are bounded to local [-1,16] on each axis; outside reads refuse. Missing is distinct from air but acts nonoccluding/nonmatching for decisions; outside world height is known air. Hidden ore remains present in raw input. No access to unloaded terrain, block-entity mutable state or larger-radius live queries.
Invalidation and verified owners
Inspected at base 8b736c3cbc; paths relative to crates/. Existing owners
below are integration points, not claims that the proposed epochs already exist.
| Route | Verified file:line | Required behavior |
|---|---|---|
| Initial mesh and remesh | horizon-client-render/src/streaming.rs:2237, :1142 | Prepare before scheduling, use frozen input in worker |
| State write and neighbour borders | horizon-client-render/src/world.rs:385 | Dirty all radius-one dependent sections, including diagonals and vertical borders |
| Column replacement, biome/light changes | horizon-client-render/src/streaming.rs:3455, :3476 | Invalidate halo dependencies even if centre state is unchanged |
| Visibility toggle | horizon-client/src/game/modding/native_render_visibility.rs:13 | Explicit refresh increments policy epoch and remeshes affected loaded sections; conservative all-loaded refresh initially |
| Resource reload | horizon-client-render/src/streaming.rs:1277, :1283 | Replace context, rebuild plans and sections |
| Renderer recovery and pending uploads | horizon-client/src/game.rs:14166, :14178 | Clear pending old uploads and use shared reload invalidation |
| Unload | horizon-client-render/src/streaming.rs:516, :1394 | Cancel queued jobs, invalidate lifetime, remove terrain, dirty surviving halo dependents |
| Stale workers | horizon-client-render/src/streaming.rs:1887 | Extend sequence/column checks to all captured generations and recycle refused output |
| Existing mask consumption | horizon-client-render/src/mesher.rs:1766 | Preserve built-in behavior and raw input separately |
Chunk arrival also invalidates surviving sections that captured Missing; use the streaming column installation boundary shared with replacement. Every plan, job and pending upload carries section lifetime/revision, halo revisions, resource, mod and visibility-policy epochs (and #1508 tint generation). Check again immediately before upload. Unload/reload at identical coordinates has a new lifetime; cancellation alone is insufficient. Old resources stay leased until old jobs finish, but old results publish zero uploads. Arbitrary mod state changes require explicit visibility refresh; no per-tick polling.
Trust boundary, targets and cost
Native and wasm receive the same generated bounded Wire snapshot and return identical integer masks; no borrowed game representation crosses either ABI. Reject invalid tags, unknown/stale ids, duplicate coordinates, masks above 63, and positions outside [0,15]. No floating-point visibility values are accepted. Use at most 4096 position records and 64 KiB encoded visibility output per section, checked before allocation; charge decoded storage against #1508's 32 MiB section and 128 MiB in-flight ceilings. Admission waits at the global limit. Snapshot sharing counts storage once, leases remain charged until drop.
A bad result refuses that registration's entire section answer atomically. Native unwind panics and wasm traps/fuel exhaustion disable the registration for the generation, emit one diagnostic and invalidate dependent plans; rebuild using valid defaults and remaining listeners. No partially applied masks. Native abort cannot be recovered; native callbacks require the supported unwind boundary and cannot be forcibly preempted. Wasm gets a finite batch fuel budget. Teardown invalidates mod generation and drains leases before unloading code.
No subscriptions selects the existing mesher once at dispatch: zero extension snapshot allocations, callbacks, listener scans or new per-face dynamic dispatch. Subscribed decisions are batched once per section; inner loops read data only. Benchmark acceptance targets less than 1% no-mod throughput regression; no performance measurement is claimed by this documentation change.
Acceptance matrix
Future implementation tests, not results of this note:
| Input | Expected value |
|---|---|
| Isolated opaque cube, Keep, mask 63 | 6 model quads |
| Same cube, mask 61 (up removed) | 5 model quads |
| Two adjacent opaque cubes, default | 10 exterior quads |
| One cube wholly hidden beside another | 0 hidden quads, 6 remaining cube quads |
| HideTerrain with fluid and one #864 quad | 0 terrain quads; independent block-entity draw retained |
| Hidden state in raw snapshot | Original state id unchanged |
| Border neighbour Missing, arrives, unloads | Exposed cube faces 6, then 5, then 6 |
| Write at local (15,15,15), neighbours loaded | Exactly 8 radius-one sections dirtied |
| Resource A completes after B; unload/reload same coordinate | 0 stale uploads in both cases |
| Two translucent quads at distances 2 and 5 | 2 translucent quads, distance 5 sorted first |
| Mask 64, duplicate position, 4097 records, panic or trap | Whole faulty answer refused; default cube 6 faces |
| Native and wasm same snapshot/answers | Identical masks, occlusion and face counts |
| No subscribers | Baseline mesh, 0 extension allocations and callbacks |
Proposed stages
File none: do not file implementation issues until Ari posts the decision. First approve granularity, raw input and occlusion semantics. Then implement shared snapshot/epoch infrastructure with #1508, followed by generated native and wasm preparation adapters together. Finally integrate #864 contribution masking and exercise the route matrix, counters and throughput benchmark.
Open questions
- Does section hiding include independently drawn block entities? Recommend no: preserve R2 lifecycle ownership and make a separate explicit lifecycle choice.
- Should masks force faces visible? Recommend suppression only; whole-cell hiding already exposes neighbours and avoids contradictory occlusion declarations.
- Can mods read beyond one block of halo? Recommend no, matching #1508; a future radius extension requires explicit dependency and admission budgets.
- Are the proposed output limits sufficient? Recommend 4096 records/64 KiB as initial ceilings and representative native/wasm measurements before raising them.
- What exact public name and refresh receiver should ship? Recommend one section visibility preparation contract and a natural client-world handle refresh, finalized with #1508 rather than a broad new Host service.
Design: mod datapack registries
Provisional contract for #811, not an implemented API. Ari's side posts the decision on the issue. This note files no issues or PRs.
Decision: decoder and generation ownership
The declaring mod owns the typed codec and its schema. The game owns decoding orchestration, candidate storage, reference resolution, validation, generation assignment and atomic publication. A decoder never installs values into live state. The runtime retains its callable only while the declaring mod is loaded; unloading requires closing its worlds first. No raw native pointer or wasm guest address is a registry value in host storage.
Propose one load-time registrar, corresponding to DataPackRegistryEvent.NewRegistry, with the appropriate Host and a scoped registrar. Illustrative declaration: example:spell, type Spell, data codec {power: integer 0..100}, optional network codec and explicit client requirement. Names and signatures await API review. Require namespaced registry keys owned by the declaring mod; reject a second declaration even with an identical codec, reporting both owners. Freeze declarations after dependency-ordered loading. Other mods and packs may contribute entry names in their own namespaces. Entry path for example:spell, entry addon:fire, is data/addon/example/spell/fire.json; pin this proposed mapping in tests before implementation. The namespaced registry folder is NeoForge's rule for datapack registries outside minecraft (its Registries guide, section "Datapack Registries"); vanilla's own registries use the registry's path only, and registration refuses minecraft keys, so the vanilla form never applies to a mod's registry.
Codecs consume bounded immutable data plus a candidate registry lookup, returning a typed immutable value or structured error. No world mutation, I/O, retained Host, scheduling or active-generation lookup during decoding. Resolve references against the complete candidate graph, including forward references; reject unresolved references and unsupported cycles. Use one generated codec/value representation for native and wasm, with typed reads through registry handles. Erasure is internal and checked against the declaration's type token; no unchecked downcasts or guest memory retained after calls.
Evidence and limits
Vanilla 26.3 was inspected only through cd /workspace/horizon && python3 tools/rea_vanilla.py method net.minecraft.resources.RegistryDataLoader load --overload 0. It accepts context lookups, registry descriptors and an executor, creates resource-manager load tasks per descriptor, and delegates to the common loader returning a future of frozen registry access. This supports descriptor-driven candidate loading; it does not prove arbitrary mod registration, reload scope or failure atomicity. Those are Horizon proposals here. Vanilla caller coverage and the distinction between world-generation registries and reloadable registries still need oracle fixtures before implementation.
The public registry guide was reachable but labels itself 26.1. It is background only, not proof of 26.3 behavior. The 26.3 NewRegistry Javadoc was requested and inaccessible through the reader. Exact overloads, network codec semantics, absent-mod behavior, reload guarantees and failure policy are unverified. The issue names the counterpart and asks for declarations and entry decoders. No NeoForge source, patches or other project's source was read. Do not advertise exact counterpart equivalence until public documentation verifies it.
The public NeoForge 26.3 changelog (maven.neoforged.net, neoforge-26.3.0.102-changelog.txt) and the pull requests it names (GitHub descriptions only; no source read) add what the 26.1 guide could not: 26.3.0.20-beta "[26.3] Add support for registering custom reloadable datapack registries" (#3527, with a cleanup of the registration event and hooks), 26.3.0.44-beta "[26.3] Add dedicated builder for datapack registries" (#3572, whose first option is a RegistryValidator for the registry), 26.3.0.58-beta "[26.3] Gather Datapack Registry Entries Before Data Providers" (#3587) and 26.3.0.88 "[26.3] Improve datapack registry definition storage" (#3659: world, reloadable and synced registry definitions are all kept in lists mods can read). So NeoForge 26.3 distinguishes world registries (read at world load) from reloadable ones, which the earlier open question about which categories reload anticipated. Horizon's custom registries reload with /reload and are validated by their schema and bounds, so they correspond to the reloadable kind with a built-in validator; Horizon has no world-load-only kind, and adding one would be a separate decision. The Javadoc for the builder's exact methods was still unreachable, so no signature is claimed.
Decode, commit and retained identity
Decode after pack selection and overlay precedence have selected one winning file per entry, before any live table or network publication. Reuse existing pack conditions and overrides; never invent another pack scanner. Include prepared base data even when there are no overlay files: the early return in build_world_tables must not skip declared registries. Report mod, registry, entry, pack, file and field path for invalid data. Duplicate declarations are registration errors; multiple pack definitions are ordinary priority overrides. Invalid winning data must fail, never fall back to a lower-priority value. Initial failure prevents world admission. Reload failure retains all old tables, registry values, generations and client snapshots.
Use a world/session identity plus monotonically increasing u64 registry-set generation. Initial successful publication is generation 1, every successful reload increments once, including identical content. Refuse exhaustion instead of wrapping. Failed reload does not increment. A handle contains session, registry type, generation and entry identity. All access and use checks them against the active set: generation 1 handles and held entry wrappers return StaleGeneration after generation 2, even if names and slot numbers match. Deleted entry lookup returns MissingEntry. Closing and reopening a world invalidates session identity. A retained entry wrapper must validate on every read; do not expose an Arc or reference that bypasses validation. Explicit copied plain data is a snapshot, carries no live entry authority, and cannot be passed as a current entry. Persist names, never session slots; re-resolve explicitly after load.
Prepare codecs, references, derived tables and wire payloads before commit. Publish the registry set with WorldTables and dependent data under one coordinator at a safe server boundary. Consumers cannot observe mixed generations. Existing reload-listener apply callbacks run before world preparation today; implementation must move fallible preparation before effects or constrain callbacks to candidate-local effects, rather than claiming existing ordering provides rollback. Listener panic or guest trap aborts the candidate; no partial publication. Catch native unwind at the boundary, disable/report the failing callable under runtime policy; process-aborting panics cannot be recovered.
Verified Horizon route owners
Paths and lines are inspected at base 8b736c3cbc; these identify integration owners, not implementation claims.
| Route | Owner and required treatment |
|---|---|
| World open, including local/integrated and dedicated Game entry | crates/horizon-client/src/game.rs:11805 selects packs; line 11808 calls build_world_tables. Prepare generation 1 here before admission. |
| Shared table builder | crates/horizon-client/src/game/world/world_entry.rs:218 owns build_world_tables and overlays; line 511 reuses it for reload. Add candidate registries to its prepared result. |
| Explicit reload and datapack selection | crates/horizon-client/src/game/administration/functions.rs:208 owns reload_datapacks; line 948 owns cmd_reload; lines 253 and 278 prepare/install world tables. All run_load authoritative routes share this coordinator. |
| Content inventory changes | crates/horizon-client/src/game/modding/mods/mod.rs:1329 calls reload_datapacks(true). Declaration changes cannot sneak through this route: require a new mod-load session; data-only changes use normal reload. |
| Additional content refresh | crates/horizon-client/src/game/administration/functions.rs:1281 prepares listeners and line 1301 prepares world reload. It must use the same candidate/commit boundary, not a second generation allocator. |
| Table publication | crates/horizon-client/src/game/world/world_entry.rs:337 owns install_world_tables; line 505 owns prepare_world_reload, including wire preparation. Extend both, keeping failure before publication. |
| Reload callbacks | crates/horizon-client/src/game/world/server_reload/mod.rs:52,100,117 own prepare, apply callbacks and store admission. Enforce the candidate ordering described above. |
| Remote client | reload_datapacks at crates/horizon-client/src/game/administration/functions.rs:251 excludes remote worlds from authoritative preparation. Remote clients receive server candidates; never decode local datapacks as server authority. |
| Join negotiation and send | crates/horizon-client/src/lan/conn.rs:1048,1084,1150 collect/decode/compare mod lists; line 1222 sends RegistryData. Extend negotiation before sending custom entries. |
| Client resource reload | crates/horizon-client/src/game/world/packs/mod.rs:240 owns reload_packs. Resource-only refresh must not advance authoritative datapack generations. |
This is the inspected production route inventory; tests also call the shared functions directly. Exact receive-side configuration commit and all vanilla callers remain implementation trace prerequisites; packet tests alone are not proof of client publication coverage.
Clients and absence negotiation
Propose three explicit modes: server-only (no network codec), synchronized-required, synchronized-optional. A network codec defines a bounded client projection, which may omit server secrets. Negotiate registry key, declaring mod, schema fingerprint and mode before any payload. Required missing mod or incompatible codec refuses join with a precise reason before entry decoding. Optional absent client mod receives no payload and gains no fabricated empty registry; server gameplay may not require that client's interpretation. Server-only registries do not require the client mod merely because they exist. Reconcile this with current mod-list comparison so blanket mismatch refusal does not silently defeat optionality; preserve unrelated dependency requirements.
Server is authoritative. Send a full sorted snapshot initially and after reload with session, generation, entry keys and bounded projected values. Client decodes and validates off to the side, acknowledges readiness, then activates at the agreed boundary; generation-tagged traffic is held until activation. Failed required-client decode disconnects that client without exposing a partial set; successful server reload need not roll back for a disconnected peer. Never send generation-2 references to a peer still using generation 1. Late joins receive only the current complete generation. Network numeric slots are scoped to this snapshot and never persisted. Transport and configuration re-entry details require protocol review; do not create a parallel registry packet store.
Existing registrars, runtime, and cost
Existing register_* hooks declare built-in content or codec types at mod load; they do not become per-reload callbacks. The new registrar declares the registry schema once; loading its JSON happens each candidate build. add_server_reload_listeners remains for independent resource consumers, not a second way to declare this registry or publish entries. Data maps annotate existing entries and remain a separate feature.
crates/horizon-mod-runtime/src/registry_keys.rs:6 owns load-time Requests and line 18 owns Names, including requested slots and resolved maps. These tables are name/slot machinery, not typed datapack value storage or a reload generation authority. Reuse their validated key resolution where appropriate; place immutable per-world generation sets under the world coordinator. Keep mod-runtime engine-independent. Generate native and wasm registration, codec calls, typed reads and refusal errors together; both use the same dependency ordering and semantics. Native exact-build checks and wasm interface checks remain their existing admission rules.
Treat files, decoder results and network bytes as untrusted. Proposed hard defaults: 1 MiB per encoded entry, depth 64, 65,536 entries per registry, 64 MiB aggregate encoded candidate data, and 256 registries per session. Check lengths before allocation, decoded collection counts as well as encoded sizes, identifiers, finite floats, schema ranges, reference count and total decoded memory budget. Bound decoded memory to 128 MiB per candidate and callback recursion to 64; wasm additionally consumes its existing fuel/memory limits. Native callbacks require trusted executable admission and cannot be forcibly time-limited safely; document this limit. Over-budget candidates fail wholly, never truncate. Refuse only custom registry keys owned by this feature; unrelated unknown-key behavior remains with its existing owner.
Resolve declarations once. With none, skip custom scanning, codec invocation, candidate allocations and synchronization extensions; no per-tick mod scans. Reads validate session/generation in constant time and perform indexed lookup. Reload work scales with selected entries and references, not ticks. Measure the empty declaration path and native/wasm bounds independently.
Acceptance matrix for implementation
Expected values below are proposals, not tests reported passing.
| Fixture | Expected value |
|---|---|
| Test mod declares example:spell; base fire power=3, higher pack power=7 | Typed fire.power=7, generation=1, exactly one selected entry. |
| Reload override to power=9 | New read=9, generation=2; held generation-1 handle and entry both return StaleGeneration. |
| Winning override power=101 or malformed JSON | Reload fails with pack/file/field error; power=7 and generation=1 remain; no snapshot sent. |
| Two mods declare example:spell | Admission fails with both owner names; zero published custom registries. |
| Entry refers forward to addon:ice, power=4 | Resolves after candidate completion; absent ice refuses entire candidate. |
| Remove fire on successful reload | Fresh lookup=MissingEntry; old handle=StaleGeneration; generation=2. |
| Identical successful reload; failed reload afterwards | Generation advances 1 to 2 once; failure leaves 2. |
| Close world then reopen with same entry and slot | Old wrapper=WrongSession; new generation=1 with distinct session. |
| Required client mod missing/schema mismatch | Join refused before payload; zero custom entries installed. |
| Optional client mod absent; server-only registry | Zero bytes for omitted registries, no empty fake registry; join succeeds if other requirements pass. |
| Present native and wasm client, power projection=7 | Both read 7 in generation 1; activation after full decode only. |
| Client reload decode traps | Zero partial entries; client disconnected; server retains committed generation 2. |
| 1,048,577-byte entry, depth 65, 65,537 entries, NaN result | Each candidate refused; old generation unchanged; subsequent valid reload succeeds. |
| Native unwind or wasm decoder trap | Candidate discarded, no sends, old value=7; next valid candidate can publish. |
| No declaration; resource-only reload | Zero custom decode calls/allocations/sends; datapack generation unchanged by resource refresh. |
| Every authoritative route above with override=9 | Exactly one publication and generation increment; remote local packs cannot replace server value. |
Proposed stages
File none. Stage 1: obtain exact public 26.3 contract and vanilla load/reload caller fixtures; settle the decisions below. Stage 2: generated registrar and bounded codec representation for native and wasm, duplicate/type validation and test-mod fixtures. Stage 3: candidate ownership, generation-checked reads and common atomic coordinator across every listed route, including listener failure. Stage 4: negotiation, snapshot transport, receive-side activation and absent-mod fixtures. Stage 5: acceptance matrix, route parity, adversarial limits and empty-path allocation measurements. No stage authorizes Rust work in this documentation task.
Stage 3 progress
Built in horizon-mod-vocab (world/datapack_registry_set.rs), engine-neutral and tested without a world: DatapackCandidate::build decodes the winning file of every entry of every declared registry and resolves references against the whole candidate (forward references work, an absent target refuses everything, a reference to an undeclared registry is left to its owner); DatapackRegistrySet publishes it as generation 1 of a session, reload gives the next generation (once, even for identical content, never wrapping), and DatapackHandle reads check session then generation, answering WrongSession, StaleGeneration, UnknownRegistry, MissingEntry or, with no world open, Unavailable. The game builds the candidate in build_world_tables and publishes it at world open and on /reload, and a mod reads it on the server through level_datapack_entries, level_datapack_handle and level_datapack_read. Every route in the table above ends in install_world_tables, so each publishes once: explicit /reload and datapack selection, a content inventory change (reload_datapacks(true)) and the additional content refresh all prepare their tables with prepare_world_reload and install them there, and a world open publishes through open_world_tables. A remote client builds no tables, so it publishes nothing. Not built yet: the listener ordering (candidate-local writes until publication).
Stage 4 progress
Built in horizon-mod-vocab (world/datapack_snapshot.rs), engine-neutral and tested without a connection: DatapackRegistrySet::snapshot takes the synchronized registries (never server-only ones) that a peer's negotiation lets through, as a DatapackSnapshot of session, generation and, per registry, its key, the schema it was decoded with and its entries sorted by id. It encodes with the wire format, bounded at 64 MiB, 256 registries and 65,536 entries per registry. On the client, DatapackSnapshot::candidate checks the snapshot against the client's own declarations and builds nothing unless everything passes: an unknown registry, a schema that differs from the local declaration, a required registry that is missing, a bad or repeated id, a value outside its field's bounds, or a reference to an entry the snapshot lacks each refuse the whole snapshot. A declared registry the snapshot lacks (server-only, or optional and not negotiated) stays empty, never a fabricated registry. DatapackRegistrySet::received publishes the candidate at the server's generation under the client's own session, so a held handle fails across a reload and across a reconnect as on a server.
The join carries it (horizon-client/src/datapack_net.rs). A Horizon client that declared synchronized registries sends horizon:datapack_offer (key and schema of each) after the mod list. Before any registry data the server answers on horizon:datapack_registries with the snapshot of the registries both sides have, built by snapshot_for: a Required registry the client did not offer with the same schema ends the join with a reason naming the mod and the registry (a vanilla client, or a Horizon one that sent no offer, is refused the same way), and an Optional one is left out with nothing sent. The client checks the snapshot with candidate while configuring; a snapshot that fails ends the join and nothing is kept. The received set is what a client mod reads through level_datapack_entries, level_datapack_handle and level_datapack_read, under a session of the client's own.
A reload that publishes the next generation sends every joined Horizon client a new snapshot on the same channel, built from the offer it made at the join (owned by that live connection and released when its senders are dropped). The client checks it as at the join and swaps its set in one step, keeping its session, so a handle from the generation before fails as StaleGeneration. A server refusal or encoding failure disconnects that client with a named reason instead of silently retaining its previous generation. A snapshot that fails the check, or whose generation does not come after the one held, ends the connection with the old set unchanged until then; the server's committed generation does not roll back for a disconnected peer.
What was left open in the first draft of this stage is settled by how the code is ordered, not by new code. (1) Activation versus play traffic: install_world_tables publishes the set and sends each joined Horizon client its snapshot first, through the connection sender, which preserves packet order; what a mod sends to players is queued in native_net.to_players and leaves at the tick's end (native_net.rs). So on one connection the snapshot always precedes every mod message queued in the reload's tick, and a message queued earlier in that tick (naming the old generation) arrives after the swap and reads as StaleGeneration, which a mod already handles. A client never sees a message that names a generation it has not yet received, so no holding queue is needed. (2) Reconfiguration: the declarations (registries, schemas, required or optional) come from the loaded mods and are fixed for the process; a reload changes entries only, so the negotiated set of registries cannot change under a joined client, and the snapshot a reload sends is built from the offer made at the join. A change of the mod list needs a new join, as for every other mod content. (3) Wasm: level_datapack_entries, level_datapack_handle and level_datapack_read are methods of LevelsHost, and their types (DatapackHandle, DatapackValue, DatapackReadError) derive Wire, so the generated wasm host exposes them exactly as it does level_biome_at; the client's implementation reads the received set. The generated-API checks of CI cover the projection.
Snapshot transport uses ordered fragments on the same custom-payload channel, for configuration and play alike. Each fragment starts with DRS1, a little-endian 32-bit total length and byte offset, followed by at most 512 KiB of data. Totals above 64 MiB, changed totals, wrong offsets, duplicate starts and truncated fragments are refused with a named reason. The client keeps its previous set until the complete snapshot decodes and validates; configuration cannot finish with an incomplete transfer. Reconnect clears both the old set and any partial transfer.
Vanilla's Registry Data packets accumulate one registry per packet and validate at Finish Configuration; Known Packs omits data already held by the client. Neither mechanism specifies arbitrary fragmentation of a single large custom payload. This channel therefore uses the small explicit total/offset scheme above, rather than pretending vanilla fragments custom payloads. Its fragments fit below the vanilla unknown clientbound custom-payload limit (1 MiB), the protocol's 21-bit frame bound and its 8 MiB decoded-packet bound, even for incompressible data. The 64 MiB bound applies to the entire decoded snapshot, not each packet.
Open questions
- What does the exact 26.3 counterpart guarantee? Recommendation: require reachable public Javadoc before implementation; retain explicit Horizon proposals where public documentation is silent.
- Which registry categories reload? Recommendation: custom registries here always replace on reload; do not infer that vanilla world-generation registries also reload. Verify their distinction with oracle fixtures.
- Shared codec API with #813? Recommendation: use one generated bounded value/codec foundation, with separate registry lifecycle ownership; avoid a second transport representation.
- How should callback ordering become atomic? Recommendation: complete all fallible world and registry preparation before effectful listener apply, and require candidate-local writes until publication; review existing callback semantics before changing them.
- Are optional synchronized registries safe for all consumers? Recommendation: permit omission only through explicit declaration and negotiation; required gameplay interpretation must select required mode.
- Are proposed budgets appropriate? Recommendation: retain the concrete limits above as provisional defaults, measure representative packs, and approve changes before freezing the API; never accept unbounded values.
- Exact client receive and reload activation owner? Recommendation: trace configuration collector through world admission and reconnect/reconfiguration before coding transport, then add verified file:line owners and tests to this note.
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()inhorizon-mod-wasm/src/lib.rs). Calls, statics, generics, trait objects andDropinside a group are ordinary Rust (tests/real_library.rs). - Mod capability keys.
CapabilityKey::<dyn ResourceHandler<Chemical>>::new("chemlib:chemical"), providers throughRegistrar, lookups throughlevel.capability. A provider whose handler type belongs to a mod is stored erased (OwnProvider,parts/capabilities.rs). On wasm, an erasedArc<dyn Any + Send + Sync>crosses as a group-scoped resource id. The group that made it gets it back, and any other group getswire::Unsupported::CROSS_GROUP(wire_resources.rs, generatorwire/mod.rsERASED). - Hook machinery.
horizon_mod_runtime::subscribers::Subscribersbuilds per-hook lists once at install. Order is priority descending, then dependency order. It also tracksreceive_cancelledand keeps a bitset for the "anyone listening?" test.set_mod_enabledandmark_modhandle the player's switch and crash attribution. - Typed slot keys.
BlockKey(u32)and the other slot keys are backed byhorizon_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:
| Shape | Bytes | MIN | take |
|---|---|---|---|
| Named record | each field's form, in declaration order, with no header | sum of the fields' MIN | inside input.nested(…) |
| Tuple record | each field's form, in order | sum of the fields' MIN | inside input.nested(…) |
| Unit struct | nothing | 0 | Ok(Self) |
| Enum with no payloads | u32 LE of the case's declaration index (explicit discriminants are ignored, as today) | 4 | not nested; an unknown index is WireError::Tag |
| Enum with payloads | u32 LE declaration index, then the payload fields in order | 4 | inside input.nested(…); an unknown index is WireError::Tag |
put_ownedmoves each field without.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_exhaustiveadds 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:
| Value | Shape | MIN | Bytes |
|---|---|---|---|
Vec3 { x: 1.0, y: 2.0, z: -0.5 } | record | 24 | 00 00 00 00 00 00 f0 3f 00 00 00 00 00 00 00 40 00 00 00 00 00 00 e0 bf |
Direction::North | enum (case 2) | 4 | 02 00 00 00 |
BlockStateId(7) | tuple record | 4 | 07 00 00 00 |
Tag::Byte(5) | enum with payload (case 1) | 4 | 01 00 00 00 05 |
Some(1u16) | std, hand-written | 1 | 01 01 00 |
vec!["ab".to_string()] | std, hand-written | 4 | 01 00 00 00 02 00 00 00 61 62 |
serde_json::json!(1) | JSON string | 4 | 01 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::typeswrites 532 of them at54bee0f608: 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,ModelPartandMeshDefinition. - 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
manualscan that skipped hand-written impls;Kind::Json; thekind()/reachable()rules (wire/ty.rs:635onward) that read crate-visible fields from outside the type; and the use ofpublic_pathto 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 plusAny). These come from theEXPORTSlist, not from types. - The constructors for borrowed records (
__wire_record_N, 463 of them), and thePutimpls 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 andPutimpls 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. Insidemod_apithis resolves through the existingextern crate self as mod_api, so one expansion serves both. There is nocrate =option. - Generics. Each type parameter gets a
T: Wirebound, 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 strfield works through the existingWire 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 "Wireis for owned values; a borrowed record crosses as a call argument". Borrowed records stay with the call tables, as above.
- A
- Recursion.
TagholdsVec<Tag>, and that works unchanged becauseMINnever recurses through a list (a list'sMINis 4). A record that holds itself directly can't exist in Rust, and one that holds itself throughBoxgetsBox<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.
Wiregains a hidden methodfn layout(visiting: &mut Vec<&'static str>) -> u64. It is not a const: mod_api has recursion through more than one type (TextholdsTextContentandVec<Text>, andTextContentholdsVec<Text>andOption<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 invisiting. 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. - the type's unqualified name (not
-
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]>andArc<[T]>, hashed withT::layout; - one string kind for
String,Box<str>,Arc<str>,Cow<str>and&'static str; - one map kind for
BTreeMapandHashMap, with key and value; - one set kind;
Box<T>andArc<T>transparent, the same asT.
Every container hashes its kind, then its contents'
layout(visiting): element, key and value; all fields for tuples; element andNfor[T; N]. That keeps the container's shape even when its element is recursive, so a change deep inside aVec<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. - one list kind for
-
WIRE_LAYOUT. It becomes the hash of the call tables' signatures (as now) plus thelayoutof every type in them, computed once at first use (LazyLock) in place of today's generated const. This replaces hashing the generator'sDefdebug strings. Any field change in any crossing type still changes it, and a mod built against another layout is still refused withRefusal::Layout. -
A layout per type.
cargo horizon-mod buildrecords thelayoutof each publicly reachable concrete type carrying a derive or hand-writtenWireimplementation inlayouts.jsonat 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 intests/test_mod_api_boundary.py, next totest_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
internalsmod 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_apitypes); - a mod hook's event and a mod registry's value:
ModHookandModRegistryrequireE: 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:
Wireis safe code, so an impl can't cause memory unsafety.Readeritself checks lengths, UTF-8 and the depth of whatnestedwraps, but tag validation and callingnestedare each impl's job: a hand-written impl can skip them. That is why the trust boundary is the host's decoding, notReader.- Everything the game decodes is a
mod_apitype, a std type or an engine mirror, decoded bymod_api's own impls (derived or hand-written inmod_api, never by a mod). Orphan rules stop a mod from implementingWirefor amod_apitype or an engine type, so the host always decodesmod_apivalues withmod_api's own impls. - 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
| Where | Count | Becomes | Why |
|---|---|---|---|
wire.rs: numbers, bool, char, (), strings, Option, Result, lists, sets, maps, arrays, tuples, Duration, &str | 23 | stay hand-written | foreign std types, the base the derive builds on |
generated: serde_json::Value, Map | 2 | move to wire.rs by hand | foreign types; JSON string form kept byte for byte |
generated: ModelPart, MeshDefinition | 2 | hand-written JSON impls beside the types | keeps their bytes; a structural derive later would be a deliberate layout change |
| generated: other value types | 528 | #[derive(Wire)] on the type | the rules above |
wire_types.rs: Transaction, TransactionSeal | 2 | stay hand-written | game-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, RegistrationContext | 2 | stay hand-written | zero-byte lent tokens: the receiver rebuilds its own token. Boundary policy, not a layout |
wire_types.rs: OwnProvider; wire_slots.rs: Id | 2 | stay hand-written | resource handles (group-scoped ids, the Unsupported marker) |
wire_types.rs: slot keys (9 u32, UnresolvedFluid u64) | 1 macro | stay hand-written | the 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, …) | ~35 | derive, after checking the field order matches the hand impl's put order | same 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: AttachmentHolder | 1 | derive | already a declaration-ordered u32 case plus payload |
attachment_query.rs: AttachmentWriteToken, AttachmentBatch | 2 | stay hand-written | tokens with generation checks |
wire_patch.rs: RecordPatch | 1 | deleted 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 themanualscan, which goes away) and the generator's emit fixtures. - Tests that change:
layout_includes_private_borrowed_shapeshashes 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_formeddescribe calls, which still cross, so they stay. - Crossing layouts:
crossing.rsis deleted by #2242. TheWIRE_LAYOUTvalue changes once, in the derive PR, which is expected and refuses old wasm builds.
PR plan for the derive
| PR | What | Size (estimate) | Depends on |
|---|---|---|---|
| D1 | One 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 |
| D2 | Mods: documented wire API, per-mod layout in cargo horizon-mod build metadata, load refusal naming the type, a book page. | ~400 | D1 |
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.
| Point | Rule |
|---|---|
| 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. |
| Side | Server, 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. |
| Contract | The 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. |
| Registration | TANK_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. |
| Order | Priority descending, then dependency order, then registration order within the mod. This is Subscribers' rule. |
| Cancellation | When 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 transactions | Listeners get the host the poster passed, so their writes run inside the poster's transaction (every hook gets a Host). |
| Who posts | Any mod that can name the hook. In practice that is the library and its addons. NeoForge doesn't restrict posting either. |
| Lifetime | Declarations 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. |
| Errors | A 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) { … } }
| Point | Rule |
|---|---|
| Name | Namespaced by the declaring mod, declared once, with the same refusals as hooks. |
| Scope | What 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. |
| Targets | An 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. |
| Multiplicity | One 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. |
| Order | Dependency order, then registration order within a mod, as Attached keeps it. |
| Contract | The 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. |
| State | Parts are shared behaviour. Data for each instance lives in attachments, as #2328 proposes. |
| Lifetime | Attached 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 }
| Scope | Created | Dropped | Reached through |
|---|---|---|---|
Server | on first use after server_about_to_start | after server_stopped | ServerHost |
Level | on first use in a loaded level | after that level's level_unload | ServerHost plus LevelRef |
ClientSession | on first use after the client logs in | after ClientPlayerNetworkEvent.LoggingOut | ClientHost |
- 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
AttachmentTypeonAttachmentHolder::Level, which is NeoForge'sSavedDataon a level. Server-wide saved data has no holder today; question 4 asks whether to addAttachmentHolder::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, soTneedn't beSync.get_muttakes&mutof the host, so the borrow is exclusive: a listener can't holdNETWORKSwhile 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,
getreturnsNone. A consumer that requires the service says so withServiceKey::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() }
| Point | Rule |
|---|---|
| Registry name | Namespaced 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. |
| Entries | Named 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. |
| Order | Dependency 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. |
| Freezing | The 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. |
| Slots | Fixed 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. |
| Clients | With 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. |
| Tags | data/<ns>/tags/<registry ns>/<registry path>/…, through the existing tag loader. Part kinds (section 2) can target these tags. |
| Data packs | A 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 point | Across targets |
|---|---|
| Plain calls, statics, generics | Refused 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 hooks | Work: 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 registries | Work 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 interfaces | Refused 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 state | Never 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::eligibleadmits 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,unsafeandexternfunctions;- methods that consume
self; impl Traitin the signature;-> !;- anything under
cfg/cfg_attr, or#[test]; #[inline(always)];- parameters named
cancelorresult.
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_functionsinhorizon-mod-api-gen/src/points.rs):- free functions;
- methods of inherent
implblocks 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 undercfg. 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)".eligibletreats 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 samekind(head, return), the samepriority, 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.&mutparameters are copied in and out. The record hascancelandresultwhere 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-targetapilistener on a parameter without it is refused at registration, naming the parameter and type, exactly as #2242 does for engine types. A cross-targetapilistener (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'smod-points.jsonrecords 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,apilisteners 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". Engineinternalsis 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 allowsinternalsthere and refuses it across targets (owner, on #2339).
-
Generics. A generic function gets the
apilevel 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 needsT: Wireat that instantiation, or the listener is refused. -
Signature check. Each mod point carries a hash, recorded in the library's
mod-points.jsoninside its.hmod. Listeners pin it in their[[inject]] hash, written bycargo horizon-mod inject add. The hash is what engine internals use (internals::signature_hashinhorizon-mod-api-gen: the first 8 bytes of SHA-256, as hex) over:- the canonical path;
- the signature's token text: receiver, parameter names and types, return type;
- for
apideclarations, thelayoutof 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_CHANGEDpath thatinject updatealready 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
AtomicBoolslot 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()):
- Walk the mod crate's module files, as
module_files/file_functionsdo. Runeligibleon each function and skip#[mod_api::no_point]. - 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. - Emit the mod's
mod-points.json: path, kind, side, record,api_refusal, hash. Emit itsmod_points-style module: records, typed slots,PointId. Both are build outputs, recorded in the build identity and covered by the package hash. - 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):
- Every point table, the game's and each mod crate's, is a
Tablein 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. #[point(table = "…")]expands against its table: the game'smod_pointsby default, a mod crate's generatedcrate::__horizon_pointsfor a mod point. Mod points have no engine mirrors.stage_mod(horizon-mod-api-gen/src/points/mod_crate.rs) is the staging pass for a mod crate.cargo horizon-mod build --wasmruns it on every member, and the launcher on every native mod crate it compiles.- 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. - 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.
- Wasm members dynamically share each mod library, but each member links its own
mod_apicopy. 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.
| Stage | What | Size | Depends on |
|---|---|---|---|
| 0 | This note. | docs | — |
| D1–D2 | #[derive(Wire)], as section 0's plan. First. | see section 0 | #2242; D1 before or with the restructure |
| P | Mod 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,500 | D1, #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. | ~600 | the restructure (lands into runtime) |
| 2 | Mod 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. | ~900 | 1, and #2242 merged (shares listener shape and order code) |
| 3 | Mod registries: ModRegistry, slots through Names, sync during configuration, tags, saves by name. | ~900 | 1; coordinate the key space with #2332 |
| 4 | Part kinds on the shared table, including registry scope. | ~600 | 1, 3 (for registry scope) |
| 5 | Scoped state and ServiceKey; AttachmentHolder::Server if question 4 says yes. | ~600 | 1 |
| 6 | Mixed 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 + ~800 | D2, 2, 3; question 1 |
| 7 | Optional: #[mod_api::extension] cross-target trait calls. | large | 6; 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
- 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.
[[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.- Capability keys and Part kinds. Should
CapabilityKeybe merged intoPartKind(a part kind with a context and first-answer lookup)? Recommendation: keep NeoForge's name and API for capabilities and share only the storage. - Server-wide saved data. Should
AttachmentHolder::Serverbe added for state that belongs to a server and not a level? NeoForge's public saved data guide attaches such data toMinecraftServer#getDataStorage. - Replacing a service provider. May a mod override another mod's service provider? Recommendation: no, refuse two providers, until a concrete case exists.
- The "registries frozen" moment. Which existing hook is the first after registration seals? If none fits, a Horizon-only hook is needed.
- 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]. #[wire(via = T)]. Is one conversion attribute on the derive acceptable? The alternative is keeping about 25 more impls hand-written.
Design: moving structures
Proposed contract for arismoko/horizon issue #794; this document requests design agreement and does not claim an implemented feature. One game-owned carrier entity owns a sparse collection of local block cells, their block entities and attachments. Mods control it through typed handles and Parts, without depending on game internals.
Proposal reviewed and representation chosen
horizon-rust/horizon#5313 proposes ownership, collision, persistence, synchronization and rendering, and leaves carrier identity, residency and rotation undecided. Its maintainer comment places the work on the Later roadmap and explicitly calls for several pull requests. Adopt its entity direction and reuse of transformed block drawing. Reject a controller block entity as the authoritative owner: crossing chunks must not split ownership. Reject a disguised vanilla entity type; reserve a game-owned protocol type with a negotiated feature requirement. General mod entity registration is not a prerequisite. Reject piston redirection as a prerequisite: it changes a different operation and cannot express rigid rotation. Include rotation in the initial simulation contract; do not ship translation semantics that later require replacement.
The proposal's existing-source locations are historical. Verified current foundations
are crates/horizon-world/src/level/pistons.rs:22 (single-block MovingPiston),
crates/horizon-client-render/src/collisions.rs:85 (entity AABB collision channel),
crates/horizon-client/src/game/entities/minecarts.rs:2679 (transformed block models)
and crates/horizon-client/src/game/entities/text_displays.rs:299 (their display use).
These are integration foundations, not an existing moving-structure implementation.
Vanilla 26.3 evidence: running python3 tools/rea_vanilla.py method net.minecraft.world.level.block.piston.PistonMovingBlockEntity tick from
/workspace/horizon shows progress advancing by 0.5, collided/stuck entity movement,
and final replacement with neighbour updates; clients retain a completed piston for
up to five death ticks. Retain ordinary stationary block semantics; do not generalize
that short piston animation into structure lifetime. No vanilla carrier counterpart
exists. No NeoForge or mod implementation source was consulted.
Ownership and transactions
A level-owned carrier has a UUID, generation, dimension, transform, revision, cells and local scheduled work. Cells are integer local positions with block state, optional block-entity payload and attachments. There is exactly one ticker and one inventory owner per cell. Assembly transfers ownership; it never clones live block entities.
Assembly preflights a bounded selection in loaded, ticking chunks, permissions, block support and cell epochs. A server callback may veto within a transaction; revalidate the exact selection, epochs and residency after callback writes. A mismatch refuses without partial transfer. Successful commit removes world tickers, transfers attachments and scheduled work, creates the carrier and emits ordinary world removal notifications once. Reject external pending piston moves or unsupported block entities. No drops are produced by transfer.
Disassembly requires a grid-aligned translation and a cube-symmetry rotation, an explicit block-state rotation adapter, loaded writable destinations and no occupied cell. Preflight all cells before mutation. Refusal preserves the carrier. Commit transfers each payload once, installs world tickers and then runs neighbour updates. Unsupported rotations never silently rewrite facing properties. Deletion is a separate explicit transaction: dismantle into storage or authorized destruction with drops; entity death must never implicitly duplicate or discard carried inventories.
Transform and contact contract
For local point p, pivot c, translation t and normalized quaternion q, world position
is t + c + R(q)(p - c). Store f64 translation and canonical normalized rotation;
block states stay in their local orientation while carried. World queries and rendering
transform shapes, normals and interaction rays rather than rewriting states each tick.
Broad phase uses the union of transformed cell bounds. Narrow phase uses the actual block voxel boxes transformed to oriented boxes. Use swept separating-axis tests for translation, and conservative advancement with bounded angular intervals for rotation; an unresolved interval stops motion conservatively. Never substitute the carrier's outer AABB as a solid shape: gaps must remain traversable. Stable UUID/cell ordering breaks simultaneous-contact ties. Motion commits only a collision-safe fraction; contact resolution and carried entity displacement commit together. No tunnelling, crushing through static walls or client-supplied contact authority is permitted. Standing players and mobs receive the contact point's rigid displacement, including rotation; jumping releases support. Explicit passengers use entity riding rules, separately from standing contacts. Projectiles and ray targeting use the same local shape index; explosions and melee address local cells through a validated hit handle. Pathfinding may conservatively avoid moving geometry until navigation support lands.
Route and lifecycle coverage
The common server-level owner is currently
crates/horizon-client/src/game/world/levels.rs:652 (tick_server); guest action scope
is crates/horizon-client/src/game/network/lan_actions/turns.rs:6 (as_guest).
Integrate the proposed carrier manager there so integrated, LAN and dedicated modes
share simulation. The following are required routes for the staged implementation,
not claims that the existing piston/display paths already support carriers.
| Route | Proposed owner and outcome |
|---|---|
| Assembly, command or mod request | Server carrier manager executes the same atomic transfer. |
| Translation, rotation, standing, pushing, riding | Server contact solver advances once per level tick; clients interpolate. |
| Local block-entity tick and scheduled tick | Carrier local level adapter ticks once; adjacent cells resolve locally. Unsupported world-coordinate-dependent tickers refuse assembly until adapted. |
| Use, mining, melee, projectile, explosion, automation | Server validates UUID/generation/local cell, reach and revision; local adapters preserve ordinary permissions and transactional Parts. World queries outside the structure use transformed positions explicitly. |
| Chunk crossing | Level manager retains ownership; acquire bounded swept-volume tickets before motion. Missing tickets pause motion and local ticking, without partial advancement. |
| Dimension crossing | Explicit atomic transfer after destination residency and collision preflight; otherwise refuse. Ordinary movement never changes dimension. |
| Unload, shutdown, restart | Persist the complete carrier before releasing tickets; dormant carriers do not tick. Resume only after full residency. |
| Disconnect or controller removal | Carrier survives independently; controller references are weak handles. No implicit disassembly. |
| Save of a parked dimension | Same carrier serializer, integrated with the parked-level save route at crates/horizon-client/src/game/world/levels.rs:1040. |
| Late join, tracking entry, tracking exit | Snapshot before deltas; exit disposes client state only. |
| Resource reload, render disposal | Rebuild model caches using new resource generation; release meshes on tracking exit/disconnect. |
| Occupied or unloaded disassembly target | Refuse atomically; ownership and counter remain in carrier. |
A local adapter must explicitly opt block types into supported ticking, scheduled work, menus, capabilities and neighbour semantics. Redstone stays local; cross-boundary connections require an explicit bridge Part. Do not make arbitrary vanilla tickers believe local coordinates are stationary world coordinates.
Save, synchronization and rendering
Persist versioned cells, canonical registry names, block-entity data, attachments, local schedules, UUID, transform and simulation revision in a level carrier store. Chunk references are indices, never duplicate payload owners. Journal assembly and removal together with affected chunks so crash recovery replays a single committed ownership transaction. Unknown required content quarantines the complete carrier without ticking or deleting its data. Unsupported schema fails load explicitly.
Tracking uses transformed bounds and dimension. Send a bounded manifest and segmented snapshot with checksum, then revision-ordered transform and cell deltas. Clients expose nothing until the complete snapshot validates. Gaps request a fresh snapshot; older generations cannot resurrect a removed carrier. Server checks interaction packets against authoritative geometry. Negotiate support before joining a world with carriers.
Interpolate translation and shortest-arc quaternion rotation between server samples; teleports reset interpolation. Cache local block meshes by cell/resource revision, reuse transformed model drawing and add local block-entity renderer contexts. Transform lighting sample points to world space; translucent sorting uses camera world distance. No client ticker may change authoritative inventory or counters. Typed DrawContext services expose rendering to mods without exposing Game or GPU ownership.
Proposed mod_api surface and trust boundary
These names are proposed additions, not available APIs. LevelRef::assemble returns
StructureRef (UUID plus generation). StructureRef::motion, cells, disassemble
and attachments have natural handle receivers; reads take Host and writes ServerHost.
StructureCellRef includes carrier generation and cell epoch. Scoped
StructureMotionPart supplies a desired rigid step; StructureCellPart declares
transfer, local tick and state-rotation support. Existing item/fluid/energy Parts are
reached through the carried cell's holder under the same transaction. Register once,
resolve dispatch at load and expose the same generated native/wasm boundary.
Each lifecycle callback receives ServerHost; rendering receives ClientHost and
DrawContext. Decide, fire, revalidate, apply; no parallel compatibility hook family.
Treat all mod values, persisted data and network payloads as untrusted. Initial limits: 4,096 non-air cells, local coordinates -128..127, 16 MiB decoded carrier data, 64 KiB per cell, 64 resident chunks per carrier, translation at most 1 block/tick, rotation at most 5 degrees/tick, and finite world positions inside the world border. Reject duplicate cells, arithmetic overflow, invalid registry values, NaN/infinity, zero quaternions and over-budget snapshots before allocation or mutation. Normalize finite nonzero quaternions; refuse excess motion rather than silently clamping it. Budget transformed shape boxes and collision iterations as well as cell counts; exhaustion refuses the step. Revalidate bounds after callbacks. Catch unwind at the mod boundary, roll back staged writes and disable the failing callback for the session; wasm traps follow the same policy. Native abort cannot be recovered by this contract.
Tick cost scales with resident carriers, active local tickers and nearby candidate contacts; cache shape indices and dirty geometry. With no carriers there is no world cell scan or per-mod dispatch. Never allocate per cell merely to interpolate a frame.
Concrete acceptance scene
Use two unit solid cells at (0,0,0) and (1,0,0), pivot (0,0,0), translation (10,64,10), identity rotation. Origins are (10,64,10) and (11,64,10). Positive 90-degree Y maps +X to -Z: after eighteen accepted 5-degree steps, origins are (10,64,10) and (10,64,9). One carried counter starts at 40: one active tick yields 41, with zero world ticker remaining; a paused tick leaves 41. Save/restart preserves both cells, transform and counter. Late join matches the existing client's revision.
Translate the identity carrier toward a wall at x=13 by one block per tick. Its initial maximum x is 12; the first step reaches 13, the next is blocked with zero penetration. A supported actor at (10.5,65,10.5) moves to (11.5,65,10.5) on that first step. In a separate three-cell-span fixture containing only x=0 and x=2 cells, a ray down through local (1.5,2,0.5) misses; an outer-AABB collision implementation fails this case. An obstacle intersecting only the intermediate angular sweep must stop rotation even when both endpoint poses are clear.
Occupied disassembly leaves two carrier cells and zero destination writes. Clear the targets and disassemble at a grid pose: two world cells, one world counter ticker, zero carrier cells. Replay the old handle: stale-generation refusal and no writes. Repeat with ticket refusal, disconnect, parked dimension and crash recovery. Run the same scene on integrated, LAN and dedicated servers with native and wasm callers; invalid motion followed by a valid built-in step must leave normal simulation working. These are required future acceptance tests, not results of this documentation change.
Proposed board issues
File none during this design task. Each slice can land disabled until its dependencies exist; each includes its own refusal and lifecycle tests.
- Carrier data model and bounded codec: local cells, UUID/generation and transforms; test exact transform values and malformed input without gameplay integration.
- Transactional ownership store: atomic assembly/disassembly, journal recovery and parked-level persistence; depends on 1, tests crash replay and single inventory owner.
- Residency and scheduling: ticket budgets, pause/resume and dimension transfer; depends on 2, tests no ticks during incomplete residency.
- Local block adapters: opt-in ticking, schedules, attachments and transactional capabilities; depends on 2, tests counter 40 to 41 and unsupported-type refusal.
- Rigid contact kernel: swept translation/rotation and sparse shape queries, usable independently in a geometry harness; tests gaps, angular sweep and iteration limits.
- Server contact integration: standing, pushing, passengers, ray/projectile/explosion routes and validated interactions; depends on 3–5, tests common server modes.
- Tracking protocol: feature negotiation, bounded snapshots, deltas and reconnect; depends on 1–3, tests reordered packets and late join without requiring rendering.
- Client drawing: cached block and block-entity meshes, interpolation, reload disposal; depends on 7, tests transformed output and resource invalidation.
- Public handles and Parts: generated native/wasm surface and callback rollback; depends on 2–6, tests stale handles, invalid output and panic/trap recovery.
- End-to-end enablement: acceptance scene across server modes, both mod targets, measured contact/tick budgets and user documentation; depends on 6–9.
Open questions requiring agreement
- Which vanilla block entities enter the first support set? Recommend explicit adapters for passive inventories plus a simple ticking counter before world-dependent machines; unsupported types refuse assembly. A per-type audit belongs to issue 4 above.
- What aggregate server budget should apply across many carriers? Recommend a global residency and contact-work quota in addition to the per-carrier limits above; choose default totals from the acceptance workload measurements before enablement.
- Is crash-atomic cross-region transfer acceptable as a prerequisite? Recommend yes: ordinary independent chunk saves cannot guarantee single ownership after power loss. Agree the journal storage format in issue 2 before exposing assembly publicly.
Design: multi-block placement
Proposed contract for #855, not an implemented feature. This replaces the fixed placement preview. The decisions below require review before implementation.
Evidence and limits
Vanilla 26.3 was inspected with python3 tools/rea_vanilla.py method <class> <method> from /workspace/horizon: net.minecraft.world.item.BlockItem place, net.minecraft.world.level.block.piston.PistonBaseBlock onPlace, and net.minecraft.world.level.block.TripWireBlock onPlace. BlockItem writes first, applies item block state and block-entity data, calls setPlacedBy, triggers placement criteria, emits sound and a game event, then consumes one item. Piston onPlace calls checkIfExtend on the server when replacing another block and no block entity exists; tripwire onPlace calls updateSource when replacing another block. These synchronous calls are the reason a fixed partner list is insufficient.
The permitted 26.3 EntityMultiPlaceEvent Javadoc was requested, including its extensionless URL, but returned usage_exceeded; the web reader also failed. Consequently its exact capture boundary, notification ordering, cancellation domains and snapshot representation are unverified. No claim of exact NeoForge equivalence is made. The issue identifies the counterpart; its contributor's mechanism description is deliberately not evidence for this design. No NeoForge source or patches were read. Public contract verification is an implementation prerequisite, not permission to infer undocumented internals.
Capture boundary and moment
Use full synchronous execution journaling, preserving vanilla ordering. Start an action savepoint before placement resolution (including random draws); execute real placement against reversible state through Sim. Include all successful block mutations transitively caused before the placement call returns, including setPlacedBy, onPlace, synchronous neighbour notifications, fence connection shape changes and tripwire source changes. Record positions in first-write order, with original state and final state; retain the ordered write journal internally, including repeated writes and positions restored to their original state. The payload contains each touched position once, even if its final state equals its original state. Listener writes are not added to this frozen payload.
A queued piston block event belongs to the action's effects, but a piston movement executed in a later event-processing phase does not belong to its write set. Scheduled ticks and independently processed neighbour work likewise remain outside the set even if placement scheduled them. Do not drain global queues to enlarge capture. This causal call boundary, rather than block class or elapsed tick, also defines tripwire inclusion.
Fire server-side entity_place after synchronous execution, before publishing or committing it. Hook docs must state that listeners read tentative block states, but tentative entity changes are not visible in entity rows until commit. Single is one touched position; Multi is two or more, delivered through existing Placement::Multi. Primary pos/state and placed_against preserve their existing meaning; capture placed_against before writing. Failed placement emits no entity_place.
This deliberately changes the existing “before it is written” promise in crates/horizon-mod-vocab/src/hooks/entity_place.rs:4 and hooks/notes.rs:2859: update both, generated documentation and samples together. It is a veto after tentative writes, not a pre-write preview. A separate pre-action permission hook and a committed notification are different moments if required; never alias either to entity_place or fire it twice. Any existing committed notice remains after successful root commit. No per-class hooks, revived preview or compatibility path.
Cancellation domains
| Domain | Required cancellation behavior |
|---|---|
| Block writes and direct neighbour updates | Restore in reverse mutation order, silently; do not invoke removal/onPlace/neighbour callbacks during undo. Undo every synchronous derived write. |
| Item use | Restore full stack/components and count, equipment and player action bookkeeping. Survival success consumes one; creative success consumes zero. |
| Queued effects | Discard action-local sounds, particles, criteria, game/level events and drops; publish only after root commit, in original order. |
| Piston block events | Remove only newly enqueued events; restore deduplication metadata without deleting pre-existing events. |
| Scheduled ticks | Restore queue contents, ordering, identities and deduplication state, including replaced entries. |
| Level random | Restore complete generator state and draw position; listener draws also roll back on veto. |
| Block entities | Restore removed instances and mutable contents; remove created instances and invalidate their handles. Attachments key on (position, generation); defer retirement of replaced block-entity attachments until root commit. Restore identity/generation safely; no leaked tentative handle may retarget another instance. |
| LAN/client sends | Hold block logs, light/remesh invalidations and outbound effects until commit. Cancellation sends no placement changes; protocol-required acknowledgement/correction of client prediction is allowed and reflects original blocks and inventory. |
The savepoint must also cover synchronous entity creation/removal and inventory drops. Following the residency and visibility design (#1606), defer attachment-holder retirement and journal Remove emission until root commit, as with effects. Rollback discards tentative spawns without retiring anything installed only tentatively; preserve restored entities’ attachments and join numbers. Use the existing residency boundary; no second raw path taking the entity list. Queue length truncation alone is insufficient when execution mutates existing entries. External irreversible I/O is prohibited during capture. This is a stronger explicit Horizon proposal where the unavailable public contract supplies no verified answer.
Host transactions and deferred Sim apply
The current storage transaction is not a Sim journal (crates/horizon-client/src/game/runtime/transactions.rs:192,386,403). Extend its root coordinator, rather than introducing an independent transaction API. Begin an action child savepoint under any enclosing host.transaction. Perform capture only when the deferred action reaches its ordered root-apply position, with earlier stored writes visible. Preserve the decision's target, stack, dimension and relevant read-set versions; validate before execution and again after listeners. A changed precondition refuses this action; never recompute a replacement placement.
Freeze capture, then invoke listeners in a nested storage savepoint over tentative Sim state. A listener may write another block or consume a stack through Host; reads see its pending writes. A committed nested transaction merges into this action, never escapes it. On cancel/panic/stale refusal: first discard listener pending writes and deferred effects (and undo any listener eager storage changes), then undo already-applied listener Sim writes if any, then reverse placement Sim mutations, restore item/actor and RNG snapshots, and discard the action effect buffer. Finally close the action savepoint aborted. Never apply listener deferred block writes while unwinding. Thus a listener setting C=stone and canceling leaves C at its pre-action state even when its nested transaction committed. An outer abort also undoes an accepted action. Successful listener writes apply once in recorded order after validation, under the same journal; no second placement execution. Only successful root completion flushes effects and sends.
While an action savepoint is open, refuse every save, including listener save-all, with vanilla’s save-failed message. The visibility lease gate (#2211 and its follow-up) must count the open savepoint as a lifted pass; #2211 supplies the lease foundation, not the save gate itself.
Owning routes to migrate
Paths below are relative to the repository at this design's base; these are inspected owners, not new APIs.
| Route | Owner and required treatment |
|---|---|
| World placement | crates/horizon-world/src/level/place.rs:1791 enters Sim place at line 1686; writes at 1702/1704 and companion work at 1550. Central journal covers these and all transitive writes; instrument the common write boundary, not each block class. |
| Player/server guest | crates/horizon-client/src/game/network/lan_actions/interaction.rs:241 owns authoritative guest UseOn. Single player also uses the server connection, as CLAUDE.md specifies. Route successful block-item use into the same action coordinator. |
| Mod/fake player | crates/horizon-client/src/game/modding/native_server.rs:658 dispatches PlayerAction::UseOn to guest_use_item_on; same actor, inventory and refusal semantics. Raw mod block setters remain independent writes, not invented entity placements. |
| Mob | crates/horizon-client/src/game/mobs/creatures/mod.rs:3237 selects enderman placement, line 3240 other mob placement; native_hooks/shared.rs:2562 owns mob_place_block. Capture actual execution and preserve carried-block rollback; retain the separate enderman permission moment. |
| Dispenser | crates/horizon-client/src/game/blocks/dispenser.rs:1656 calls place_block with a directional context; include its subsequent item state/block-entity work in the action. Use placer=None and restore dispenser inventory on veto. Other dispense actions that do not place blocks are excluded. |
| Remote prediction | crates/horizon-client/src/game/network/remote/mod.rs:9605 executes place_block against DryLevel. It must not dispatch this server hook; authoritative correction resolves prediction. |
| Existing hook adapter | crates/horizon-client/src/game/native_hooks/shared.rs:2508,2538 owns place_canceled/player_place_canceled and the fixed preview. Replace these callers and delete the preview transport and companion inference. |
Implementation must trace each authoritative item's use dispatch into this owner and test local, LAN, dedicated and fake-player routes. Silverfish infestation, turtle eggs and falling-block landing are currently excluded by hook notes; do not silently advertise them as covered entity placements. Adding them requires a separately verified placement contract, not a per-class preview.
Trust, panic and cost
Payload is immutable, callback-scoped and cannot be retained as live handles. Validate IDs, dimensions, positions, stack limits and transaction capabilities at Host boundaries; reject unknown states owned by this operation, non-finite coordinates, invalid counts and stale handles before a mutation. Set cancellation monotonically across listeners. Bound nesting and journal growth with a declared budget; exceeding it refuses and rolls back the action, never truncates Multi. Catch native unwinding at dispatch and treat guest traps identically: abort the whole action, report/disable the failing listener under runtime policy. A process-aborting native panic cannot be recovered by a journal.
Resolve subscriptions at load. With no entity_place subscriber, bypass capture/payload allocation and execute the normal placement route; ordinary enclosing transaction guarantees still apply. No per-tick scan, allocation or all-mod dispatch. A journal-enabled transaction must not accidentally activate the hook. Measure subscribed cost proportional to actual mutations/effects and no-listener allocation count.
Mob, dispenser and fake-player routes must guard listener unwinding: restore the lift and close the action savepoint aborted. Document panic during restoration as a limit.
Acceptance matrix for implementation
These are required expected values, not tests claimed to pass. Use fixed coordinates and explicit state properties, not implementation-generated expectations.
| Fixture | Expected result |
|---|---|
| Bed/door/tall plant at A/B, both initially air, stack 3 | Cancel: A/B air, count 3, zero published placement effects. Accept survival: both specified halves, count 2; creative count 3. |
| Fence A next to fence B with B east=false | Accepted Multi contains A and B; B east=true. Cancel restores B east=false and A air. |
| Powered piston placed at A facing east | Capture includes A; extension event is buffered. Cancel: A air, stack 3, zero new piston events. Accept: stack 2; processing the later extension event changes blocks outside this capture. |
| Two hooks at (0,64,0)/(4,64,0), facing inward; wires x=1,3; place wire x=2 | Capture includes x=2 and synchronous changed hooks/wires; after attachment hooks attached=true and wires attached=true, powered=false with no entity touching. Cancel restores missing centre and original detached states, stack 3; zero new published sounds/direct-update effects. Pin exact ordered touched-position list with vanilla before merging runtime support. |
| Cancel action scheduling ticks, drawing random and replacing a block entity | Serialized tick/event queues and RNG exactly equal pre-action snapshots; original block entity identity/data restored; created handles invalid; zero placement network sends and count 3. |
| Listener writes C=stone then cancels | C and placement positions equal pre-action states, count 3; nested listener commit cannot escape. |
| Listener nested transaction commits, then a later listener cancels; spawn inside the first listener | Placement and listener writes roll back; tentative spawn disappears, existing attachments and join numbers survive, zero published effects. |
| Save while action savepoint is open, including listener save-all | Refused with vanilla’s save-failed message; gate counts an open savepoint as a lifted pass; no tentative state persisted. |
| Panicking listener in each mob, dispenser and fake-player route | Action aborted and lift balanced afterwards; next valid action succeeds. |
| Listener changes target/stack and accepts | Stale action refused, listener child rolled back, no replacement placement, no published effects. |
| Independent tick after accepted placement | No additional member in frozen Multi; later update retains its normal effects. |
| No subscriber | Same states, queue order, RNG and inventory as ordinary vanilla-equivalent path; zero capture allocations. |
| Native panic/guest trap, invalid ID/count/NaN coordinate | Complete action rollback; next valid built-in action still succeeds. |
Proposed implementation stages and open questions
File no issues or PRs from this note. Proposed PR 1: obtain public Javadoc and vanilla oracle fixtures, finish exact caller trace and freeze hook semantics/docs. With the public contract in hand, decide whether neighbour shape changes such as a fence joining belong in Multi; the fence fixture above is provisional until that decision. PR 2: start as an early draft and require design review before code of entity retirement/journal emission, block-entity attachment identity and save refusal above (review items 1–3). Add transaction-owned Sim savepoints, queue/RNG/block-entity undo and buffered publication with adversarial nested rollback tests, including a real attachment and real join number across entity removal/rollback and tentative spawn discard. PR 3: replace fixed preview with captured Single/Multi across every route, update generated API/native/wasm consumers and remove old helpers. Test each mob, dispenser and fake-player route with a panicking listener and verify a balanced lift. PR 4: run the acceptance matrix and allocation/parity measurements before declaring #855 complete.
Genuinely open: does the published counterpart require a narrower write set or a different moment? Recommendation: verify its public contract before code; if it conflicts, bring the explicit difference to review rather than relying on contributor internals. What finite journal/nesting budget is appropriate? Recommendation: select from a measured worst-case valid placement, counting spawned entities and block entities as well as block writes, with deterministic refusal at the boundary. Exact tripwire touched-position ordering remains an oracle task; recommendation: pin the five-position fixture above before implementing payload assertions. No other cancellation domain is left optional by this proposal.
Design: offline players and selection
Provisional answer to #989, including Ari’s release comment: move the existing user cache into each authoritative server Game lifecycle; extract one immutable selector evaluator with an explicit ordering policy. Read-only random selection uses a required caller seed and a temporary RNG, never gameplay RNG. This is an unimplemented design with provisional maintainer decisions on persistence, randomness and paging. No stages have been filed.
Evidence and limits
Vanilla 26.3 queries: python3 tools/rea_vanilla.py method net.minecraft.commands.arguments.selector.EntitySelector sortAndLimit confirms ordering precedes limiting and runs only with more than one result. python3 tools/rea_vanilla.py method net.minecraft.server.players.CachedUserNameToIdResolver save confirms persistence of the top 1000 MRU profiles and ignored IOExceptions. The attempted EntitySelectorOptions <clinit> query failed; exact vanilla shuffle RNG/draw trace remains unverified. Vanilla client Services is created with the game directory; its client cache is scoped to that directory. Do not describe seeded service results as vanilla random-command parity.
The issue explicitly names no NeoForge counterpart. The public 26.3 Javadoc index was reachable and identifies 26.3-26.3.0.1-beta; it does not establish an offline enumeration or immutable random-selection contract. Those NeoForge capabilities and limits remain unverified; require public documentation before adopting any counterpart claim. No NeoForge source, patches or other project source was read.
Verified owners and routes
Paths and lines are from the inspected worktree base; proposed new ownership below is not present yet.
| Route | Existing owner and proposed treatment |
|---|---|
| Cache load, dedicated | crates/horizon-client/src/game/interface/dedicated/mod.rs:943 loads UserCache, :949 installs the static declared at :171. Transfer that same instance into server-owned state; remove the static and redirect all consumers. |
| Join | crates/horizon-client/src/game/network/lan/mod.rs:2646 calls dedicated::player_joined, implemented at game/interface/dedicated/mod.rs:174. Move admission recording to the shared successful join boundary for dedicated, integrated owner and LAN guests; failed authentication is excluded. |
| Existing lookup consumers | game/interface/dedicated/mod.rs:184 cached_user_by_id and :1625 name resolution must use the same instance. Reads must not call mutating get_by_id/get_by_name. |
| Cache storage | crates/horizon-server-files/src/user_cache.rs:38 UserCache, :53 load, :121 add, :136 get_by_id, :145 find_by_name, :210 serialization, :227 save. Add immutable enumeration over authoritative UUID entries here, without parsing JSON or exposing internal records. |
| Integrated creation / shutdown | game/world/integrated.rs:849 supplies owner profile, :898 stop_integrated, :949 stop_serving. Construct cache for the serving Game, not the window or remote client. LAN publication shares this owner. |
| Dedicated shutdown | game/interface/dedicated/mod.rs:2376 stop_dedicated must flush/drop its owned cache, including failure cleanup. |
| Server identity | game/modding/native_lifecycle.rs:66 NativeLifecycle, :85 epoch, :124 begin_close, :131 close failure, :144 close final. Bind service access to this existing identity; no second generation counter. |
| Selector | game/administration/commands/mod.rs:795 select, :835 level switching, :855 random ordering, :870 select_in_level, :1141 command-level switching. Extract filtering and ordering once; commands and service both call it. |
| Typed syntax | crates/horizon-command/src/args.rs:334 Sort and :345 Selector, plus selector.rs:99 parser. Keep one parser; public records are API-owned conversions, not parser internals. |
The game paths in the table abbreviate crates/horizon-client/src/. Remote windows and LAN guests have no authoritative service; server commands, integrated owner, network guests and dedicated console ultimately use the same server evaluator. Command execution may still switch levels to apply actions after selection; immutable evaluation itself must not.
Per-server records
Expose offline_players through the focused object-safe server player service using &dyn ServerHost. Return owned API UUID/name records, never PlayerRef or save paths. “Known offline” means a current, nonexpired authoritative cache UUID record absent from the server-wide connected-player set, including profiles learned by successful name resolution; it does not mean every UUID with a playerdata file or every account ever seen. Deduplicate by UUID, use the latest authoritative name, order by UUID, and evaluate expiration against the host call’s captured time without eviction or MRU writes. A disconnected player becomes visible immediately; reconnect excludes them immediately.
Each serving Game owns exactly one existing UserCache. Dedicated keeps its configured universe cache location. The provisional decision is world-local usercache.json for integrated (singleplayer) worlds, selected by server startup configuration; LAN shares it. This is a deliberate scope choice, not vanilla placement: vanilla’s client cache is scoped to the game directory. Only UserCache loads/saves it. No service file reader, directory scan, global fallback or profile-network lookup. Separate server roots isolate records; refuse concurrent writable ownership of the same cache path rather than permitting racing writers. Explicitly sharing a persisted root across sequential lifetimes intentionally restores its records.
Initialize before ready, record the owner on successful admission, flush through the existing persistence path on orderly shutdown, and drop state on failed open or close as well. Retire access through NativeLifecycle; a retained service token cannot read the next world. Report persistence failures through server diagnostics without claiming durable success from this read API. Preserve existing cache retention rather than imposing the 1000 serialized-entry limit on in-memory enumeration.
One immutable evaluator and explicit randomness
Expose select_entities on a focused object-safe server selection service. API-owned SelectorSpec and SelectionSource records with explicit derive(Wire) contain supported filters, sort, limit, source level, finite position/rotation and optional existing EntityRef. Resolve source identity through existing lifecycle/entity holders; reject stale, foreign-server or wrong-level source handles. No implicit mutable command source. Console @s returns empty. Relative positions use the supplied source; spatial constraints restrict the source dimension, while unconstrained queries retain canonical loaded-dimension traversal. Never force-load chunks or levels.
Extract a borrowed SelectionView over active and parked levels and all connected players. Move every existing predicate, candidate-order rule, distance calculation and tie rule into one evaluator over that view. Audit NBT, scoreboard and advancement predicates for hidden mutation. Unsupported predicates return UnsupportedSelectorCapability before evaluating, with identical native/wasm support; never silently skip a filter. Existing commands parse into this same evaluator. No second selector, cloned Game, swap-to-read, RefCell or mutex-backed mutation.
Separate evaluation from ordering entropy. The command adapter supplies its existing gameplay RNG explicitly to the common ordering routine and preserves command draw order. The provisional decision requires caller-seeded reads: encode ordering as Random { seed: u64 }, making a missing seed unrepresentable after decoding. Only the seed crosses Wire, never an RNG object. Initialize a local Java-compatible RNG from the seed, use the same descending Fisher–Yates routine and bounded draws, then limit. Before claiming parity, add an oracle fixture against vanilla’s Collections.shuffle, since Horizon currently consumes world RNG. Pin independent command and seeded-read fixtures before extraction ships; preserving today’s command RNG trace does not certify vanilla parity. Zero or one candidate takes zero draws. Nothing is stored between calls. Same seed, candidate order and state yields the same result; changing a seed need not change the result. Repeated reads never advance world/entity RNG, a global query counter, MRU state or wall-clock entropy. This is deliberate reproducible sampling, not a promise of fresh randomness per call. A caller wanting fresh samples chooses new seeds outside this service.
Return live EntityRefs through existing entity holders, including parked-level identities; do not invent query-specific generations or publish provisional residency. Revalidation on later use is mandatory. Reads see the current authoritative action view but emit no journal events; outer rollback invalidates any tentative handles through existing rules. If extraction requires lifting a pass, retirement and journal emission remain deferred to root commit. These services add no save operation; existing saves must remain refused while an action savepoint is open.
Transport, bounds and failures
Generate native and wasm service glue and Wire records from the catalog together; no target-specific helpers or compatibility aliases. Both targets validate once at the input boundary, including encoded sizes and predicate counts before parsing or allocation: finite coordinates/rotation/distances, ordered nonnegative ranges, valid selector kinds, positive limits, bounded strings and predicate counts. Recommend 256 predicates, 32 KiB encoded spec and 4096 records per response page, with bounded continuation tied to the existing lifecycle and view revision; changed views return SelectionChanged rather than mixing pages. Pages must preserve complete vanilla candidate capacity: never cap the server population or silently truncate results. Capacity-preserving paging is the provisional decision: service byte budgets must never silently reduce what vanilla commands can select. Numerical limits remain proposed pending transport feasibility checks. Account for names, handles, envelope overhead and temporary sort buffers in byte budgets, not just record counts.
No mod callback is introduced, so there is no new callback registration. If later registration exposes predicate providers, refuse it as UnsupportedSelectorCapability until a documented read-only native/wasm contract exists. Native unwind or wasm trap at the existing host boundary returns failure and no partial response; release borrows and temporary buffers, perform no selector retry or gameplay mutation, and retain normal runtime diagnostics. Do not claim panic=abort recovery.
No subscriptions, per-tick cache rebuilds, all-mod scans or query allocations when unused: the added query cost is O(1) when uncalled. Existing admission cache work remains at admission; explicit queries scan matching authoritative records/entities and allocate only bounded response/work buffers. No-listener command behavior and gameplay RNG trace remain unchanged.
Acceptance matrix
These are proposed implementation fixtures, not tests executed for this note.
| Fixture | Expected result |
|---|---|
| UUID 1/Alex joins then disconnects, unexpired | Exactly [(1, Alex)] on integrated, LAN host and dedicated; reconnect yields []. |
| Cached UUID 2/Steve learned by resolution, no join | Included while offline; expired UUID 3 excluded with zero MRU/cache writes. |
| Empty world / console @s | [] / []; zero random draws. |
| Source (10,64,10), entities at (12,64,10) and (30,64,10), radius 3 | Exactly first entity; source offset +2 included, +20 excluded. |
| Seed 7, unchanged ordered candidates, two reads | Identical ordered handles; gameplay RNG state before = after both reads. Next command random result equals baseline without reads. |
| Seeded random over [A,B,C], limit 2 | Two distinct members; independent vanilla Collections.shuffle oracle pins exact permutation before extraction ships; separate command fixture pins its entropy/draw trace. |
| Active and parked dimensions | Unbounded query sees both; radius query sees source dimension only; no level swaps or loads. |
| Server A root / server B different root | A disconnects UUID 1; B returns []; old A token is stale after shutdown, including failed shutdown. |
| Invalid/trapped request | NaN, limit 0, oversized spec and foreign source each fail before traversal; native unwind/wasm trap yields no partial results and unchanged RNG. |
| 5000 records | With sufficient byte budget and proposed page sizes, pages 4096 and 904, no loss; changed lifecycle or revision refuses continuation. No 1000-entry in-memory cap. |
| Integrated reopen / different world | Same world restores records; another world is isolated; LAN shares its host cache. Verify complete Services handoff. |
| Random ordering Wire round trip | Random { seed: u64 } retains seed; missing seed cannot decode; no RNG object crosses Wire. |
| Large names/handles and vanilla-capacity selection | Budget names, handles, envelope and temporary sort buffers; preserve all results and command capacity. |
| Predicate audit / malformed encoded input | Transitive predicates remain immutable; validate encoded sizes and predicate counts once before parsing/allocation on native and wasm. |
| No service calls | Zero new per-tick allocations/scans; command selections and RNG match prior behavior. |
Proposed stages
File none. First establish remaining public/vanilla evidence and transport feasibility under the recorded provisional decisions. Then relocate cache ownership and all lookup/admission/shutdown routes. Extract the shared immutable evaluator and verify command parity before adding services. Finally generate API/Wire transport, docs and sample together and implement lifecycle, seeded-order, paging, native/wasm and failure fixtures. Coordinate commands/mod.rs with #2253/#1863; this note changes no Rust.
Remaining evidence
Implementation must still verify NeoForge counterpart capabilities (none established), exact vanilla shuffle RNG/draw trace and the seeded permutation oracle, complete integrated Services handoff, transport byte-budget feasibility, transitive predicate immutability and executable panic/trap cleanup. These are evidence and implementation requirements, not unresolved policy choices.
Design: Part instance state
Accepted with changes as a provisional design by the maintainer decision. The requirements below must be satisfied before code. Attachments are owned by the world-visibility lead; that lead replaces the earlier attachment owner.
Design for #796, jointly defining the state binding needed by #781. This is not an implemented guarantee. The acceptance is conditional on the requirements below.
Evidence and limits
Vanilla 26.3 was inspected from /workspace/horizon using python3 tools/rea_vanilla.py method net.minecraft.world.level.block.entity.BlockEntity saveCustomOnly --overload 0 and python3 tools/rea_vanilla.py method net.minecraft.world.item.ItemStack copy. The former builds a compound through ValueOutput and delegates custom saving; the latter creates a new stack with copied components and count (empty copies return EMPTY). These verify object-owned persistence and stack value copying, not a general retained Part identity contract. Vanilla has no Horizon Parts. General entity lifetime, block replacement and network ordering were not verified against vanilla in this investigation; the rules below are proposals, not vanilla parity claims.
The public NeoForge attachment documentation describes holder defaults, optional serialization and client synchronization, but the retrieved page is labelled 26.1. It also directs item stack data to components. The 26.3 AttachmentType Javadoc was inaccessible through the web reader. Consequently NeoForge 26.3 behavior is unverified, including defaults, serialization, synchronization, replacement/respawn copy policy, retained identity and rollback. Class-level coverage summaries cannot establish these contracts. No NeoForge counterpart is claimed for general Part identity; public 26.3 AttachmentType and holder lifecycle documentation are required before claiming equivalence. #796 names no NeoForge counterpart; #781 names data attachments. No NeoForge source, patches or other project's source were read. Rechecking public 26.3 documentation is an implementation prerequisite.
Identity versus storage
Retain scoped, non-owning handles; translate to relative storage keys only after validating the handle against its owning world and incarnation, and dimension for dimension-scoped holders. Never interpret a saved reference in the Host's currently selected dimension. A relative Holder is an internal address for an already validated operation, not a durable object identity. Retaining a handle neither loads nor pins a holder. World close invalidates all its handles. Epochs and generations must not wrap into valid old identities; refuse allocation on exhaustion.
Use the existing attachment codec/store as the only Part-data store. A Part object is shared behavior, not the owner of an instance's mutable data. Registry keys identify declarations, never instances. Persist declared values under namespaced attachment keys; do not serialize runtime handles as durable references. Mods needing durable links explicitly store a world/dimension plus persistent identifier or position and resolve afresh, accepting absence and replacement.
Inspected owners and holder kinds
Paths and lines are relative to this repository. These are current owners, not proof of end-to-end guarantees.
| Kind | Verified owner | Proposed binding |
|---|---|---|
| Block | crates/horizon-mod-runtime/src/attachments.rs:66 defines relative holders and has no block variant | A plain block has no independent attachment holder today. Plain-block chunk entries are deferred outside the first implementation stages. A separate decision must establish the block-write lifecycle owner and incarnation-preserving mutations before adding position/generation storage. |
| Block entity | crates/horizon-mod-vocab/src/lib.rs:3784 defines BlockEntityRef; crates/horizon-client/src/game/runtime/transactions.rs:68,86 captures generation | Bind attachments to (position, generation) within the scoped level. Same-position replacement creates a new generation even with the same type. Property changes preserving the instance keep its data. |
| Entity, including player | crates/horizon-mod-vocab/src/lib.rs:1610 defines EntityRef; crates/horizon-mod-vocab/src/entity/attachment_holder.rs:13 wraps entity/player handles | Entity handles must carry no dimension: validate world and lifecycle epoch with id, resolving the current level through the entity table. Each dimension has one attachment index. Transfer preserves the epoch, retiring attachments at committed removal and rebinding at arrival. Respawn is a new incarnation; copy only according to the declared copy policy. |
| Item | crates/horizon-mod-runtime/src/attachments.rs:66 has no item holder; vanilla ItemStack.copy above owns component copying | Item state belongs to the stack value through components, not an invented attachment holder. A retained slot reference identifies a versioned location, not a stack traveling through inventories. Copy/split/merge policy must be explicit; recommend copy value on split and merge only equal state. |
crates/horizon-mod-vocab/src/entity/attachment_holder.rs:13 also includes chunk and level holders: scope them to residency/world epochs. They can own backing storage without becoming a second Part-state abstraction. Block and item support are proposed extensions, not claims of existing API support.
Typed authoring and object safety (#781)
Require a typed authoring declaration with associated Data and a typed attachment declaration, erased once at registration into the existing codec/store and object-safe dispatch. Do not add associated types or constants directly to the trait currently stored as dyn BlockEntityPart (crates/horizon-mod-vocab/src/parts/block_entities.rs:37). Generate the adapter rather than duplicating native and wasm paths. Reject duplicate keys with incompatible schemas at registration. Give each hook its existing Host; obtain state through the holder's methods, never a second Host side table.
Before the first code PR, specify the minimal typed Data declaration signature and bounds, attachment key and generated erased adapter. Extend existing AttachmentHolder vocabulary rather than adding parallel Part identities. Show holder reads taking &dyn Host and writes taking &mut dyn ServerHost, with explicit missing-holder, stale-handle and schema-error results.
Persistence codec support and ABI support are separate capabilities: serialization does not establish Wire compatibility. Declare each state type's required shared Wire capability for every native/wasm boundary crossing. Unsupported capabilities must refuse registration/load with the capability and type named, before first access. Add generated coverage rows for this surface on both targets.
The first code PR must explicitly define edit reentrancy: same-holder/key recursive edits must refuse, and replacement must check the revision captured before invoking the edit closure. No mutable borrow may cross dispatch.
Prefer snapshot reads and checked replacement/edit closures over escaping mutable references. Defaults are lazy, owned per holder, and participate in transactions. Read-only absence inspection must not insert defaults. An edit commits a new encoded value only after successful validation. No data borrow may survive a callback, transaction boundary or await. This note proposes answers for both issues' shared identity/erasure questions; it does not close either issue or settle the exact authoring syntax without review. #781 can implement the binding separately after the #796 lifetime contract is accepted.
Persistence, residency and synchronization
Save only committed, codec-backed values. Codec-less values are session-only and must not request persistence or sync. Load into a fresh runtime incarnation; decode errors must not expose successfully initialized typed state; they preserve the saved bytes and report failure rather than silently overwriting them with a default. Unknown keys remain opaque, bounded saved data. A codec version mismatch requires explicit failure or an approved schema conversion, never an API compatibility shim.
Unload invalidates live access without implying logical deletion of saved data. Reload restores values but requires fresh handles. Replacement and destruction retire the old incarnation and prevent old packets or handles from addressing its successor. Follow multi-block placement and #1606: residency publication and attachment retirement occur at the existing residency boundary. No second raw entity-list mutation route. The chunk residency/unload owner must clear block-entity live attachment bindings when unloading the chunk, preserving committed saved values; implementation must name and audit that concrete owner. For block entities, defer retirement of (position, generation) attachments until root commit; rollback restores the old generation and its values, while leaked tentative handles remain invalid forever.
Current sync ownership is crates/horizon-client/src/game/modding/attachment_sync.rs:34 (whole holder values) and :158 (remote map). Reuse the shared typed payload route requested by #813, not a Part-specific packet protocol. Propose an envelope containing scoped holder incarnation, attachment key, schema and monotonic committed revision. Entity wire identity must use (lifecycle epoch, id) or the entity network id, never a residency join: joins are engine-internal per-list values, unstable across save/load and unsuitable as network identity. Send a full snapshot when tracking begins, then committed updates/removals to authorized trackers only. Reject stale incarnation/revision messages, bound snapshot buffering, and request a fresh snapshot on gaps. Client replicas cannot mutate authoritative state. Visibility loss clears replicas; rejoin receives a full snapshot. This is a required future contract, not proof that today's transport already provides it.
Transactions, trust and cost
Extend the existing root transaction coordinator (crates/horizon-client/src/game/runtime/transactions.rs:68,86,548), which already captures/restores block-entity generations. Entities spawned or removed inside an action follow the same deferral: no attachment retirement before root commit; abort restores the pre-action bindings and invalidates tentative handles. Journal defaults, edits, retirement, dirty flags and pending sync together. Nested commit merges into its parent; nested abort restores its entry state; root abort publishes zero payloads. Defer sync and irreversible effects until root commit. Save while tentative state exists must refuse through the same save gate proposed by the placement design. Revalidate holder generation and read versions at apply; stale decisions refuse rather than recomputing against another object.
Native unwinding and wasm traps abort the active action and discard tentative state and messages; report and disable the failing callback according to runtime policy. Process-aborting native panics cannot be recovered. Restoration must not call mod codecs or callbacks: capture rollback material before invoking untrusted code. Encode/decode failures also abort without partial publication.
Treat mod input as untrusted on both targets: validate scope, side, ownership, keys, schema, numeric lengths, nesting, finite numeric fields where required and payload budgets before mutation. Recommend a declared 64 KiB encoded value limit and depth 64, with oversize refusal rather than truncation; exact limits need approval. These limits apply only to this new mod-state facility; they must not constrain vanilla stack components or replace shared Wire budgets. Arbitrary domain values remain the declaration's responsibility. Wasm uses opaque validated handles and the same schema/policy and transaction rules; no guest pointers or Rust Any values cross the ABI. Native has no exemption from validation.
Resolve declarations and subscriptions at registration. With no state declaration or listener, allocate no per-holder Part data, encode nothing, send no packets and perform no all-mod/per-tick scan. Existing handle validation and transaction guarantees remain applicable. Cost with subscribers is proportional to touched values and authorized recipients.
Acceptance matrix
These are proposed implementation tests with hand-written expectations, not tests run for this note.
| Fixture | Expected values |
|---|---|
| Holders A/B, default counter 0; set A=7 | A=7, B=0; distinct default instances; Part object owns neither value |
| Same position in dimensions X/Y | Set X=7 leaves Y=0; X handle used with Y Host refuses |
| Remove generation 4; recreate generation 5 | Old handle MissingHolder; new counter 0; late generation-4 update ignored |
| Save A=7, unload and reload | Fresh handle reads 7; old handle refuses; codec-less counter resets to 0 |
| Outer starts A=7; inner sets 9 and commits; outer aborts | A=7; zero outbound updates and no committed dirty flag |
| Inner inserts default then panics/traps | No stored default after abort; subsequent valid edit to 8 succeeds |
| Placement replaces block entity then cancels | Original generation and counter 7 restored; tentative handle refuses |
| Tracking joins at revision 10, update 11 then stale 10 | Client remains at revision 11 value 8; visibility loss removes replica |
| Entity transfers X to Y | Same epoch/id handle resolves Y; old dimension index retires at commit and arrival rebinds; no dimension in handle |
| Entity spawn/removal inside action then abort | Original bindings restored, tentative handles invalid, zero retirement/publication before commit |
| Chunk unload with block-entity state 7 | Chunk residency owner clears live bindings; committed saved 7 survives |
| Internal residency join changes across reload | No join in wire envelope; stable entity identity and revision govern acceptance |
| Same-holder/key recursive edit | Explicit refusal; outer replacement checks captured revision; no escaping mutable borrow |
| Unsupported Wire capability or undecodable schema | Registration/load names unsupported type/capability; saved bytes preserved and no initialized typed value exposed |
| Stack count 4, state 7, split 1 | Counts 3/1, both state 7; unequal state 8 cannot merge |
| Bad scope/schema or 65 KiB value under proposed limit | Refusal, old value 7 preserved, zero payloads |
| No subscribers/declarations, 100 ticks | Zero Part-state allocations, codec calls and packets |
Proposed implementation stages
File: none. These are work packages, not changes made by this note.
- Approve identity, unload and item/block decisions; verify remaining vanilla routes and public NeoForge 26.3 evidence.
- Audit every handle-to-relative-holder conversion, residency owner and generation retirement path; test replacement, world close and dimension transfer.
- The first code PR must explicitly specify typed state registration (minimal signature, bounds, key and erased adapter), the shared Wire capability required by each state type, and edit reentrancy/revision rules. Implement #781 on existing attachment storage with native/wasm generated coverage and explicit capability/type registration/load failures.
- Integrate save/load and nested rollback, defaults and panic restoration; plain-block chunk entries remain deferred to a separate lifecycle-owner decision.
- Complete #813 typed payload integration and tracker snapshot/revision rules; test stale messages, unauthorized recipients and both targets.
- Demonstrate the acceptance matrix through real owners and measure the zero-subscriber path before advertising support.
This unblocks #781's per-holder Data binding, #813's shared synchronization, and #792's general entity state/lifecycle work. It supplies the state lifetime prerequisite for #855 placement rollback and #1606 residency integration; it does not implement or replace those issues.
Open questions
- Deferred plain blocks: decide the block-write lifecycle owner and incarnation-preserving mutation set separately; plain-block chunk entries are outside the first stages.
- Attachment integration: the world-visibility lead must confirm entity epoch/id identity and wire identity against the attachment index and sync code before implementation, replacing the earlier request for ecs-lead confirmation. Name the concrete chunk-unload owner that clears live block-entity bindings.
- Item identity: recommend stack value components and versioned slot access, not stable traveling item identities. Approve split/merge/copy semantics before exposing ItemPart Data.
- Authoring syntax: recommend a separate typed declaration erased into object-safe Part dispatch; the first code PR must specify whether DATA is an associated declaration or registration argument, its bounds and adapter, required Wire capabilities, and edit reentrancy rules. Do not break dyn storage accidentally.
- Unload continuity: recommend fresh handles after reload, with saved values preserved. Persistent references must resolve explicitly rather than resurrect handles.
- Transport limits and authority: recommend 64 KiB/depth 64 and server-authoritative replicas; confirm limits and revision-gap recovery with #813's owner.
- Parity evidence: recommend accepting these as Horizon proposals while requiring public 26.3 verification before claiming NeoForge equivalence. Vanilla entity/removal/network routes also need clean-room inspection during implementation.
Design: pending children
Provisional answer to #825: the engine owns an action-local ordered collection of fully constructed, unspawned children. Listeners borrow editors; the engine validates and consumes the final collection exactly once at the original insertion boundary. This is a proposal, not an implemented API or an issue decision. Ari's side posts the decision on the issue. No implementation stages have been filed.
Evidence and limits
Vanilla 26.3 was queried from /workspace/horizon using python3 tools/rea_vanilla.py method net.minecraft.world.entity.animal.Animal spawnChildFromBreeding. The returned method calls getBreedOffspring, returns for null, sets the baby flag and position, calls finalizeSpawnChildFromBreeding, then addFreshEntityWithPassengers. Thus the proposed callback observes a constructed child before finalization and insertion. Queries for Slime and AbstractCubeMob in the monster package failed class resolution; exact vanilla split method contents and species overrides remain unverified. Horizon's inspected owners below identify AbstractCubeMob.remove as their reference; those comments are not independent vanilla verification.
The issue names NeoForge 26.3 MobSplitEvent.getChildren and BabyEntitySpawnEvent.setChild. Requests to the public MobSplitEvent Javadoc and BabyEntitySpawnEvent Javadoc failed in the web reader. Exact mutability, null-child, cancellation, parent side effects and supported breeding routes are unverified. The issue establishes the desired capability, not proof of NeoForge behavior. No NeoForge source, patches or other project's source was read. Public-contract and remaining vanilla verification gate implementation.
Ownership and callback contract
Construct the complete vanilla list first, drawing inheritance, child yaw and other initialization randomness once in vanilla order. Do not finalizeSpawn or insert these children. Each entry owns its complete Mob, including opaque goal state; cloning a Mob is not an ownership transfer. Construction uses the existing unspawned-mob machinery shared with #840/#838, not a second mob store.
Provide ServerHost and a scoped collection receiver with construct, edit, replace, append and remove operations. Construct returns an unattached pending child owned by the action arena. Append/replace consumes its ownership token; it cannot be attached twice. Removal destroys the removed pending object and invalidates its editor. Unattached objects are destroyed when the callback action closes. Replacement preserves the list position, not the removed object's identity. Never accept a resident mob as a pending child. BabyEntitySpawn has a zero-or-one child slot; it supports replacement/removal, while multiple children belong to MobSplit's collection. A second baby is an error, not silently dropped.
Native and wasm use the same action identity, world epoch, slot generation and ownership checks. A callback-scoped UnspawnedMobRef is a borrow, not permission to retain or insert it later. End every borrow before nested dispatch; nested actions get separate arenas and cannot consume outer children. Reacquire a fresh editor after reentrancy. Reject expired, foreign-action, removed, already-consumed and cross-world tokens before mutation; slot reuse must never retarget a leaked handle. Earlier listeners' accepted edits are visible to later listeners in dependency order. Cancellation is monotonic.
Freeze after dispatch, validate the entire list and parent/read-set versions, then apply once. Refuse stale preconditions without recomputing breeding or replaying RNG. Ordered insertion uses the existing residency boundary and join notifications. Never retry an entered insertion consumer: retain explicit completion progress so recovery cannot duplicate earlier children. A failure before commit inserts zero; a failure after an irreversible consumer entered is a recovery error, not authorization to replay it.
Cancellation, transactions and residency
Cancellation means zero children. Preserve the vanilla construction RNG draws already performed, and preserve the parent's death/removal for splitting. Breeding cancellation provisionally keeps age 6000 and love 0 on both parents, clears breeding credit, and emits zero child-related XP, statistics, advancements and hearts. This matches the current hook's stated policy, but its exact NeoForge correspondence remains unverified. Removing the last child uses the same outcome as cancellation. Never reconstruct children from edited counts or rewind construction draws merely because a listener vetoed.
Distinguish that veto from an enclosing transaction abort: following multi-block placement, an outer abort restores its action journal, including RNG and parent mutations. Listener Host writes and accepted apply belong to one action savepoint under that coordinator. A veto discards listener writes and pending attachments while preserving the specified pre-callback vanilla construction/parent effects. A stale refusal or recoverable callback failure uses the same child suppression policy. This requires explicit savepoint boundaries; blanket rewind to before breeding is incorrect for a veto.
Follow #1606: pending children have no resident row, tracker entry, join number, save visibility or published attachment holder. Pending attachment data lives with its owned object using existing attachment machinery. Install it at successful residency commit; discard it on removal/veto without emitting resident Remove. On outer rollback, retire tentative residency and restore prior holders according to the placement journal. Do not publish joins or sends before root commit. Ordinary join vetoes must have an explicitly tested per-child outcome; they cannot trigger list replay.
Owning routes and hook migration
Paths and line numbers are inspected Horizon owners at this note's base. Vanilla method names in this table are reference targets, not claims that all were independently queried.
| Route | Owner and treatment |
|---|---|
| Slime, magma cube, sulfur cube death | crates/horizon-client/src/game/mobs/creatures/mod.rs:1745 split_dead_cube explicitly covers all three; replace count loop at 1754 with constructed collection. Reference AbstractCubeMob.remove. Size 1 and primed sulfur producing zero children do not dispatch. |
| Split child initialization | crates/horizon-client/src/mobs/cube.rs:90 specializes sulfur size/baby state; yaw draws follow at 96. Preserve these draws and list order even on veto. |
| Live animal breeding | crates/horizon-client/src/game/mobs/creatures/mod.rs:3948 breed is the common owner, reached at 3614; inheritance construction precedes finalization at 4226 and insertion at 4231/4237. Move the existing early callback after actual child construction. Include horse/donkey mule, equine inheritance, fox trust, color and variant paths in this common owner. Reference Animal.spawnChildFromBreeding and species getBreedOffspring overrides. |
| Turtle/frog eggs | Same file:4006–4015 excludes live children and preserves finalization/XP. References TurtleBreedGoal.breed and frog breeding behavior; exact vanilla frog method unverified. No baby callback for egg laying. |
| Sniffer egg | Same file:4004–4008 finalizes then lays an egg. Reference Sniffer.spawnChildFromBreeding; no live-child callback. |
| Turtle hatch | Same file:3789 hatch_turtles, reference TurtleEggBlock.randomTick. Hatching is independent spawning, outside breeding callback. |
| Spawn egg offspring | Same file:7696 spawn_egg_offspring, reference SpawnEggItem spawning behavior. Excluded from this breeding moment. |
| Villager/armadillo | Existing baby_entity_spawn declaration explicitly excludes their own paths. Exact owners and vanilla methods remain unverified; implementation must trace these before claiming full live-breeding coverage. Recommend include only if public counterpart and vanilla moment agree, otherwise document the distinct moment. |
| Native pending lend | crates/horizon-client/src/game/modding/native_unspawned_mob.rs:107 lend_unspawned already owns scoped lending and unwind restoration. Extend its ownership discipline; do not treat the stand-in clone as a child. |
Change mob_split and baby_entity_spawn in place, once per vanilla moment. Delete count/kind-only transport, obsolete helpers and the stale “handle does not exist” prose in both declarations, hook notes, generated native/wasm bindings and samples together. Retain parent/player metadata. Do not add parallel legacy hooks or a second post-construction hook.
Coordinate with open #2285 (pending output FIFO, nested consumer frames and reset lifetime), #2227 (completed outputs across unwind), and #2112 (borrowed tick restoration). These are overlapping ownership/recovery foundations, not proof of atomic staged-spawn support. Their PR descriptions explicitly retain recovery limits; do not assume any supplies this collection contract.
Trust, panic and cost
Treat all mod inputs as untrusted. Propose at most 64 split children and one breeding child; validate before allocating or consuming tokens. Reject unknown mob kinds, duplicate ownership, invalid dimensions, non-finite positions/yaw, out-of-world coordinates and invalid species sizes at the boundary. Use registered species constraints rather than a universal guessed size range. Reject an invalid operation without changing the list; a final invariant failure suppresses the action's children. Bound nesting and total arena storage; exceeding a budget refuses, never truncates.
Catch native unwinding at dispatch and handle wasm traps identically: close borrows, invalidate tokens, discard pending children and listener writes, report the listener under runtime policy. Recovery must preserve already completed unrelated outputs. Do not claim recovery from panic=abort or a second panic in restoration/destruction. No insertion runs during unwinding.
Resolve subscribers at load. With none, run today's vanilla construction/insertion directly, with zero new collection transport allocation, no registry/string lookup and no all-mod scan. Existing outer transaction requirements still apply. With listeners, work is proportional to actual children and edits. Measure both native and wasm, including allocation counts.
Acceptance matrix
Expected implementation outcomes below are fixtures to write, not tests already passing.
| Fixture | Concrete expected value |
|---|---|
| Size-4 slime/magma, fixed oracle seed yielding 3 children | Three size-2 children in original order; RNG state equals vanilla after three yaw draws. Cancel: zero children, same construction RNG state, parent absent. |
| Size-4 sulfur, unprimed / primed | Two size-2 babies / zero children and zero callbacks; size-1 parent also zero callbacks. Verify against vanilla before implementing. |
| Edit/remove/append | Initial [A,B,C], edit A yaw=90, remove B, append D: commit [A,C,D] once, A yaw=90; B borrow is stale. |
| Replace | Replace C with E: [A,B,E]; C invalid, E consumed once; second append of E returns ownership error. |
| Breeding | Replace cow child with constructed pig baby: exactly one pig, age negative, no cow; both parents age=6000 and love=0. |
| Breeding veto | Zero child, zero XP/hearts/stat/advancement; both ages=6000, love=0; construction RNG unchanged from accepted construction baseline. |
| Egg exclusions | Turtle, frog, sniffer laying and turtle hatching: zero baby_entity_spawn calls. Preserve their original egg/XP results. |
| Reentrancy and targets | Nested action cannot resolve outer borrow; retained native and wasm tokens both fail after return; zero retargeted writes. |
| Invalid input | 65th split child, second baby, NaN position, unknown kind and duplicate token each fail before mutation; original valid list remains. |
| Panic/trap before commit | Zero child joins, zero published holders; pre-callback parent/RNG effects retained; unrelated completed output delivered once. |
| Outer abort | Zero children/joins/sends; original parent state, RNG and attachment values restored. |
| No listeners | Exact vanilla child fields, ordering, RNG, XP and parent effects; zero added payload allocations. |
Proposed stages
File none. First verify the public counterpart and remaining vanilla routes and decide the questions below. Then extend the shared pending ownership boundary and native/wasm operations together. Next migrate both hooks and all authoritative routes, coordinate recovery foundations, and delete obsolete transport. Finally add oracle-backed species, reentrancy, transaction and fault fixtures plus no-listener measurements. These are proposed stages, not filed issues or authorized Rust changes.
Open questions
- Exact NeoForge 26.3 cancellation and null-child behavior? Recommend verify public Javadoc before implementation; retain the explicit proposed parent/RNG policy until Ari decides.
- Are all live births in scope, including villager and armadillo paths? Recommend trace exact owners and counterpart call moments first; do not quietly inherit today's exclusions as proven parity.
- How does a downstream residency veto affect a multi-child list? Recommend preserve the existing per-child join refusal contract and record each completion once, while buffering externally visible effects until root commit.
- Which recovery foundation is ready? Recommend coordinate #2285/#2227/#2112 before activating pending producers; require real nested and partial-consumer tests, not an inferred blanket unwind guarantee.
- Limits and child type restrictions? Recommend 64 split children, one baby, same dimension, registered mob kinds and species-valid state; permit cross-species replacement with an explicitly baby-initialized breeding child.
Design: per-biome client effects and mod biome registry sync
Provisional answer to #2734. A design note, not an implemented API; it files no issues or PRs and claims nothing. Historical leads below are not a current gap inventory: known-biome replacement is already implemented; the connection acceptance fixture remains verification work. Line numbers are at base 3be6d3ecd1 (the stage 4 snapshot branch, PR #2769) unless a branch is named.
The issue thread's starting point holds: the game already sends the world's biome registry in network order and builds the biomes a client lacks from their data. The open work is how a mod's declaration joins those tables, which changes reach a remote client, and what stays unverified.
What the client reads today
26.3 split a biome in two. Through python tools/rea_vanilla.py class net.minecraft.world.level.biome.BiomeSpecialEffects, the effects record keeps only the water colour (an int), the optional foliage, dry foliage and grass colours, and the grass colour modifier. Everything else moved to the biome's attributes map (Biome.getAttributes, an EnvironmentAttributeMap). EnvironmentAttributes lists, among others, SKY_COLOR, FOG_COLOR, WATER_FOG_COLOR, AMBIENT_PARTICLES, AMBIENT_SOUNDS, BACKGROUND_MUSIC and MUSIC_VOLUME. So in biome JSON:
| Effect | Where in 26.3 biome JSON | Vanilla type |
|---|---|---|
| water, foliage, dry foliage, grass colour, grass modifier | effects | BiomeSpecialEffects |
| sky, fog, water fog colour | attributes."minecraft:visual/sky_color", fog_color, water_fog_color | attribute |
| ambient particles | attributes."minecraft:visual/ambient_particles" | list of AmbientParticle(particle, probability) |
| ambient loop, additions, mood | attributes."minecraft:audio/ambient_sounds" | AmbientSounds(loop, mood, additions) |
| music | attributes."minecraft:audio/background_music", minecraft:audio/music_volume | BackgroundMusic(default, creative, underwater) |
Horizon reads the same split. BiomeInfo::from_json takes colours from effects and sky, fog and water fog from attributes (crates/horizon-client-render/src/biome.rs:211-259), keeps the whole attribute layer (:273), and parses ambient particles at :112-127. Three consumers each read their own attribute:
- Particles:
animate_tick.rs:652usesBiomeInfo::ambient_particlesfor a pack or server biome (pack_file(name)is set), and a table built at load for the rest. That table (animate_tick.rs:374-394) readsdata/minecraft/worldgen/biome/<name>.jsonfrom disk, so it knows only built-in biomes by file name. - Ambient sounds:
AmbientTables::at(ambience.rs:90-100) takes the biome's pack JSON when there is one, else the built-in table, else the dimension type's value. - Music:
MusicTables::at(music.rs:227) does the same forbackground_musicandmusic_volume.
Vanilla 26.3, from bytecode: ClientLevel.doAnimateTick loops environmentAttributes().getValue(EnvironmentAttributes.AMBIENT_PARTICLES, pos) for a block whose collision shape is not full and spawns each particle where canSpawn(random) (random.nextFloat() <= probability). BiomeAmbientSoundsHandler.tick reads AMBIENT_SOUNDS at the player: a changed loop fades the old LoopSoundInstance out and the new one in; each AmbientAdditionsSettings plays when random.nextDouble() < tickChance; the AmbientMoodSettings lowers a moodiness counter by sky light or (block light - 1) / tickDelay, and at 1.0 plays the mood sound blockSearchExtent blocks around, offset by soundPositionOffset. AmbientMoodSettings fields are soundEvent, tickDelay, blockSearchExtent, soundPositionOffset; AmbientAdditionsSettings are soundEvent, tickChance. Horizon's ambience.rs:15-30 mirrors them. The attributes are values the player's position resolves, so a mod needs no client code: if the biome JSON is on the client, the effect works.
The consequence for sync is that a client plays a biome's effects only from the biome JSON it holds. The three tables above are keyed by biome name and by Biomes::pack_file.
How a biome reaches a remote client today
- The server builds its registry in network order:
Biomes::registry_ordersorts by path, then namespace (biome.rs:377), the orderRegistryDataLoaderuses.SyncData(lan/data.rs:483-499) rewrites the biome entries from that table when a pack added or replaced one, flagging themfrom_pack. registry_packets(lan/data.rs:632-657) sends an entry's data when it isfrom_packor the client did not accept the vanilla pack, and omits it otherwise (None if known_pack && !from_pack).- The client fills an omitted entry from its own resources:
fill_known_entries(net.rs:1236) readsdata/<ns>/worldgen/biome/<path>.json. - Supplied biome definitions replace known names in place and append missing names. Historically, biomes the client lacked were built from the sent NBT:
client_biomes(lan/data.rs:56-76) keepsknownand runsBiomes::with_packsover the missing names only (!known.contains(name)).remote/mod.rs:2599-2611installs the table, and:2616-2631maps each network index to a table id by name, so the table's own order (built-ins first, new ones appended,biome.rs:336-352) need not equal the network order. This is also why a new biome's table id is not its network id.
The historical baseline suggested two gaps; the first is implemented:
- Known-biome replacement is implemented.
client_biomesnow forwards all supplied definitions andwith_packsreplaces names in place, covered bypacks_replace_in_place_and_add_after_the_builtin_biomes. The connection acceptance fixture remains verification work. At the historical baseline,client_biomesfilters out names the client already has, so a server that replacesminecraft:plains(a pack, or a mod) sends its data (from_pack) but the client keeps its own. The issue thread asks for exactly this acceptance fixture. - A modifier-changed vanilla biome is not flagged. Biome modifiers rewrite
data/<ns>/worldgen/biome/<path>.jsonin the content root (mod_content.rs:2554) and the server'swalkreads the root, but the entry staysfrom_pack == false, so with the vanilla pack accepted its data is omitted and a Horizon client without the same mod fills it from its own, unmodified file. Whether the dedicated server'swalkroot is the modified one I did not trace, so this is a lead, not a finding. It matters because the MODIFY phase (below) changes colours the client tints with.
Where a content mod's brand-new biome file enters Biomes is also unverified. Biomes::load reads only the minecraft folder (biome.rs:291-310); packed_biomes (world_loading.rs:590-618) adds entries from worldgen_overrides and the modifiers. I found no test that a mod ships data/<ns>/worldgen/biome/x.json and plays it.
Options for a mod's biome
(a) A datapack-registry-style registry (the stage 4 transport: horizon:datapack_offer, horizon:datapack_registries, crates/horizon-client/src/datapack_net.rs:20-22, DatapackSnapshot::candidate in crates/horizon-mod-vocab/src/world/datapack_snapshot.rs). It declares a typed, schema-checked registry of values. That is the right tool for a registry the mod owns; a biome is not one. The biome registry is vanilla's, its codec is vanilla's Biome, and a vanilla-protocol client must receive it in the configuration phase anyway (see (c)). A second transport would duplicate data the vanilla path already carries and give two ids per biome. Not recommended.
(b) The dimension-type route (#2147). A mod hands the engine a table once at startup (install_mod_dimensions, game.rs:17637, a OnceLock at crates/horizon-world/src/worldgen/overrides.rs:79), and mod_dimension_types (lan/data.rs:187-218) appends its entries with from_pack = true, so their data is always sent. Two properties carry over: declaration happens once before any world opens, and always-send avoids the known-pack hole above. One does not: dimension types go through PACK_REGISTRIES, biomes use the dedicated branch in lan/data.rs:483-499 with Biomes::with_packs.
(c) The vanilla registry sync. minecraft:worldgen/biome is first in SYNCHRONIZED (lan/data.rs:17-20), so every client, vanilla protocol included, receives RegistryData for it in the configuration phase. A vanilla-protocol client that lacks a mod biome can decode it from the sent NBT (Biome has a NETWORK_CODEC in the bytecode). Its id is the entry's index in that packet, which must be the sorted order (path, then namespace) over all biomes including the mod's; chunk biome palettes use those indices (ChunkFormat::new(..., biomes, bits), remote/mod.rs:2627). A mod biome therefore changes the network id of every vanilla biome sorted after it. That is already how a pack-added biome behaves.
Recommendation: (c) carried by the (b) pattern. A mod's biome is a normal registry entry, flagged from_pack so its data is always sent (as dimension types are), with its file declared at startup, and the client's existing client_biomes path builds it. No new packet, schema or negotiation. The replacement change is already implemented; remaining connection verification must exercise it. The original plan was: client_biomes replaces a known biome whose sent data differs from the client's own file, and modifier or MODIFY changes mark the entry from_pack. The effects need nothing else, because the three consumers above already take pack_file. A Horizon client without the mod gets the biome and plays its effects from the sent JSON; a vanilla client gets the biome and the vanilla behaviours of whatever attributes it understands. The datapack-registry transport stays for registries a mod owns.
Files to change: crates/horizon-client/src/lan/data.rs (client_biomes replacement rule; from_pack for modified and mod entries; a mod_biomes beside mod_dimension_types if the source is an install table), crates/horizon-client-render/src/biome.rs (with_packs is already the merge point; add a replace-known test), crates/horizon-client/src/game/network/remote/mod.rs (:2599-2611, rebuild when the sent table differs from a known one), crates/horizon-client/src/animate_tick.rs (:374-394: stop reading the disk file for non-built-in names; the pack_file branch already does), crates/horizon-client/src/mod_content.rs (carry a mod's biome files into packed_biomes, if that route does not exist), and the docs below.
Which effects a mod sets, and the API
A data-file mod needs no code: it ships data/<ns>/worldgen/biome/<path>.json with the fields in the table above, for a new biome, and horizon:modify_biome for a vanilla one. Sounds and particles name events and types in the minecraft or the mod's own namespace, which the client already resolves.
PR #2800 (origin/xmilon/2732-biome-modify-phase, commit 22c8f24053) adds the MODIFY phase for climate and colours: horizon:modify_biome sets temperature, downfall, has_precipitation, temperature_modifier and effects colours sky_color, fog_color, water_color, water_fog_color, foliage_color, grass_color; the code form is BiomeModifierOperation::ModifyBiome in crates/horizon-mod-vocab/src/world/biome_modifier.rs on that branch, checked by BiomeModifier::check (colour at most 0xFFFFFF). Its docs (how-to/content-mods.md) say sky, fog and water fog are written to their minecraft:visual/... attributes. What is left:
- Ambient sounds, particles and music cannot be modified.
ModifyBiomehas no field for them. Proposed additions asOptionfields in the same shape:ambient_particles: Option<Vec<(String, f32)>>,ambient_loop: Option<String>,ambient_additions: Option<Vec<(String, f64)>>,ambient_mood: Option<AmbientMood { sound, tick_delay, block_search_extent, offset }>,background_musicandmusic_volume. In the file they are the vanilla attribute objects underattributes, replacing the biome's whole value (these attributes are not merged field by field); a set field means replace, an absent one leaves the value. Validation as PR #2800: out-of-range or wrong-type values skip the modifier with a warning naming mod and file. - The JSON keys of the audio objects (
loop,mood.sound,mood.tick_delay,mood.block_search_extent,mood.offset,additions[].sound,additions[].tick_chance) are derived from the bytecode accessors above, not from a prepared biome file; confirm them against one before code. - A new-biome declaration in code (no file) is not designed here; reuse the declaration style of
declare_biome_modifierif wanted. - NeoForge: its public biome modifier page (docs.neoforged.net/docs/worldgen/biomemodifier, labelled 26.1) documents the five phases including MODIFY ("single values such as climate and colors") and no setter names for sounds, particles, music or colours. The 26.3 per-biome surface is unconfirmed; this note does not claim counterpart equivalence.
Staged plan
- Fixtures first (no behaviour change): add the tests named below against the current code to learn which already pass.
- Replacement rule in
client_biomesandwith_packs: implemented, with an in-place replacement unit test. Retain the connection acceptance fixture and verifyfrom_packfor modified entries separately. - A mod's new biome file reaches
packed_biomesand the server registry; effects play on a remote client. Status: implemented.Biomes::loadappends the content root's non-minecraftbiomes fromworldgen/biomes.jsonafter the built-ins in registry order (biome::mod_biome_files),pack_fileanswers for them, andSyncDataadds them to the biome registry withfrom_packset, so a vanilla-pack client receives their data. Not covered: a dimension generating the biome (its biome source) and reload while connected. - The ambient and music fields of the MODIFY phase, file and code form, with docs.
- Gameplay-side readers (any server logic that reads these attributes) are out of scope.
Acceptance fixtures
Expected values are proposals, not results.
| Fixture | Expected |
|---|---|
Server pack replaces minecraft:plains with effects.water_color 0x112233 and ambient particle minecraft:ash at 0.25 | client table: id of minecraft:plains unchanged, water = 0x112233, ambient_particles = [(ash, 0.25)] |
Mod adds huts:dunes beside built-ins | network order puts it by path then namespace; the client table has it appended after built-ins, id("huts:dunes") resolves by name; a chunk column keeps its biome after the join |
ambient_sounds loop minecraft:ambient.warped_forest.loop, additions chance 0.0111, mood tick delay 6000, search extent 8, offset 2.0 on the new biome | AmbientTables::at returns the same values |
horizon:modify_biome changes a vanilla biome's grass_color; remote Horizon client without the mod | after the fix, tint equals the server's; today it equals the client's own file |
Two mods both declare biome a:x | startup refusal naming both mods (as dimensions) |
| Reconfiguration to a server with fewer biomes | a stale column plays plains and never indexes past the table (existing test a_reconfiguring_client_plays_menu_music_and_never_indexes_past_the_biomes, remote/mod.rs:16003) |
Open questions
- Is replacing a known biome's data on the client allowed for every server, or only when the join negotiated a Horizon mod list? Recommendation: always, since the data arrives only with
from_packand the client already trusts the server for dimension types. - Does a vanilla-protocol client accept an unknown-namespace biome id? The wire format allows it; I could not test it.
- Reload while connected: vanilla reconfigures to change registries. Which Horizon path does a mod's biome change use on a live join?
What I could not verify
- The names and signatures of NeoForge 26.3's per-biome setters. What the public record does say (the 26.3 changelog at maven.neoforged.net,
neoforge-26.3.0.102-changelog.txt, and the descriptions of the pull requests it names on github.com/neoforged/NeoForge; no source or patches were read): the docs site has no 26.3 version (its newest is 26.1, which is the page cited above) and the 26.3 Javadoc host returned 503;26.3.0.19-beta"[26.3] Add ability to remove color overrides in BiomeSpecialEffectsBuilder" (#3528, a modifier can unset a colour override as well as set one; the PR text gives no field list);26.3.0.24-beta"Provide registries access to BiomeModifier and StructureModifier" (#3546:modifyreceives the server'sRegistryAccess);NoneBiomeModifierwas removed in favour of data loading conditions (#3165, described in #3553), soneoforge:noneis a 1.21-era form that Horizon still reads for older mods. Nothing in the changelog names a per-biome sound, particle or music setter, so those stay Horizon's ownhorizon:modify_biomefields and no counterpart is claimed. - Vanilla client behaviour beyond the methods named (
doAnimateTick,BiomeAmbientSoundsHandler.tick); music selection is cited from Horizon's ownmusic.rs, not re-read from bytecode. - That
walkinSyncData::loadreads the modified content root on a dedicated server, and where a mod's brand-new biome file entersBiomes. - JSON key names for the audio attribute objects beyond what
ambience.rsand the bytecode accessors show. No code was built or run for this note (no cargo).
Design: Read-only typed loot context
Provisional proposal for #2647. Docs only; approval precedes code. Broaden the existing LootContext snapshot at the condition/function evaluation boundary. Do not provide a mutable Host, live world access, or another loot hook. #2648 owns the narrow tags view. PR #2595 owns LevelRef::roll_loot and its FISHING-shaped input; this proposal neither expands that input nor changes random ownership.
Evidence and current routes
The public NeoForge loot-table documentation describes typed ContextKey<T>, presence tests, required/optional parameter sets, and unregistered custom keys. Its rolling context also contains level, random, luck and dynamic drops. These are distinct from parameters. The page is not pinned to NeoForge 26.3: NeoForge-specific 26.3 behavior is unverified, including custom-key registration and exact set membership. The owner-supplied read list is a later review gate for the subset below.
Vanilla 26.3 bytecode can be inspected with python3 tools/rea_vanilla.py class net.minecraft.world.level.storage.loot.parameters.LootContextParams and the corresponding LootContextParamSets class. All fifteen requested constant names exist. Important type details: TOOL is ItemInstance, ADDITIONAL_COST_COMPONENT_ALLOWED is Unit, not boolean, and vanilla additionally exposes CONTAINER (SlotProvider), absent from the public page's list. Field inspection confirms names and types, not required/optional membership. The set memberships below follow vanilla's parameter set declarations for the sets named; membership of the remaining sets is verified in stage one.
Verified repository-relative owners:
| Route | Owner |
|---|---|
| Existing API snapshot and nested Wire values | crates/horizon-mod-vocab/src/world/snapshots.rs:104, :129, :140, :174, :187, :199 |
| Condition/function catalog, authoritative server | crates/horizon-mod-vocab/src/hooks/loot_condition.rs:9, loot_function.rs:10 |
| Guarded conversion and dispatch, immutable context | crates/horizon-client/src/game/modding/api_values/loot_values.rs:7, :24, :174 |
| Engine callbacks and borrowed context | crates/horizon-world/src/loot.rs:89, :103, :120; forwarding crates/horizon-world/src/native_load_hooks.rs:40, :51 |
| World evaluator: custom condition, context capture, custom function | crates/horizon-world/src/loot.rs:1583, :1613, :2107 |
| Items evaluator: context capture, custom condition/function | crates/horizon-items/src/items.rs:2049, :2234, :2627 |
| Place scope shared by both evaluators | crates/horizon-world/src/loot.rs:187, :200 |
| Existing typed block value | crates/horizon-mod-vocab/src/block/block_state.rs:9 |
Both evaluator routes must carry the same typed slots through the common boundary. Blocks, containers, fishing and command-driven item rolls reach the items evaluator; entity drops and world loot reach the world evaluator. Caller-specific context must be carried explicitly, never reconstructed from whichever Game fields happen to be selected. Before implementation, enumerate gameplay producers and prove their parameter-set selection with fixtures; these evaluation owners do not establish complete producer coverage for interaction/enchantment contexts currently missing from the snapshot.
Parameter audit and proposed subset
R means required in the named set; O means allowed but optional. A parameter is forbidden in sets that do not allow it; generic validation is not permission to invent values. The examples below describe the proposed initial supported sets, BLOCK, ENTITY and FISHING, plus the public-doc families relevant to deferred keys. Exact remaining set membership is a stage-one verification prerequisite, not inferred from a parameter's name.
| Parameter | Snapshot today | Proposed API-owned typed form | Set semantics | wasm |
|---|---|---|---|---|
| this_entity | entity: Option<LootEntity> predicate facts | LootEntity | R ENTITY; O BLOCK/FISHING | Existing facts cross; no live entity |
| interacting_entity | Missing | LootEntity | O ENTITY_INTERACT/BLOCK_INTERACT; absent initial sets | Deferred capture, then Wire |
| target_entity | Missing | LootEntity | R ENTITY_INTERACT (which also requires tool); absent initial sets | Deferred capture, then Wire |
| last_damage_player | player predicate facts | LootEntity constrained to player | O ENTITY | Existing facts cross |
| damage_source | damage: Option<LootDamage> | LootDamage | R ENTITY | Existing facts cross; no live DamageSource |
| attacking_entity | Nested damage.source, not independent presence | LootEntity independent slot | O ENTITY; not allowed in FISHING | Facts cross; independent capture needed |
| direct_attacking_entity | Nested damage.direct, not independent presence | LootEntity independent slot | O ENTITY | Facts cross; independent capture needed |
| origin | place.origin | [f64; 3] | R BLOCK/ENTITY/FISHING | Already crosses |
| block_state | block identifier only; loses properties | Existing BlockState | R BLOCK | Wire exists; producer conversion missing |
| block_entity | place.block_entity: Option<Tag> saved metadata | Existing API Tag snapshot | O BLOCK | Already crosses as NBT; live object does not |
| tool | LootTool: item and enchantments only | Existing LootStack snapshot of instance | R BLOCK/FISHING, including empty instance | Wire exists; full instance capture missing |
| explosion_radius | Missing even though items evaluator has it | f32 | O BLOCK | Deferred today; proposed initial subset |
| enchantment_level | Missing | i32 | R enchantment sets and HIT_BLOCK; absent initial sets | Deferred producer, scalar is wireable |
| enchantment_active | Missing | bool | R ENCHANTED_LOCATION; absent initial sets | Deferred producer, scalar is wireable |
| additional_cost_component_allowed | Missing | () presence token | R VILLAGER_TRADE; absent initial sets | Deferred producer; never encode as false |
| container (additional vanilla key) | Missing | No suitable snapshot chosen | R CONTAINER_PROCESS (with block_entity, block_state and origin) | Unsupported: live SlotProvider has no read-only API projection |
These are deliberately snapshots, not claims of full Java object equivalence. Entity properties unavailable in LootEntity, arbitrary entity methods, block-entity methods, live inventory access and damage-source methods cannot cross today: the bridge has only the listed captured Wire facts and no safe read backend during split level work. Native receives exactly the same subset. Do not expose an extra native pointer. Existing entity handles, if added later, must use the existing lifecycle holder identity with no second generation; identity alone grants no reads.
Build first: independent this/attacker/direct/player facts, damage facts, origin, typed block state, saved block-entity Tag, full tool snapshot and explosion radius for BLOCK/ENTITY/FISHING. Retain luck, biome, seed and captured level facts as context metadata, not fabricated ContextKeys. Keep the engine's captured seed convention; do not expose its mutable random or consume extra draws for new slots. Interaction, enchantment, villager-trade and container producers are later work, pending verification and real-read demand.
Typed view and registration
Extend the existing API context in one clean break; remove renamed/redundant fields and update every caller, generated boundary, sample and catalog together. No parallel legacy context. Proposed LootContext::get(&LootKey<T>) -> Result<Option<&T>, LootContextReadError> and require distinguish absent optional (None), disallowed (ParameterNotAllowed), undeclared (NotDeclared) and unsupported (UnsupportedLootParameter). A read requires declaration, as vanilla validates the parameters a function references against the table's parameter set: an undeclared read answers the typed NotDeclared error, never the value and never a None that looks like an absent optional. Missing required values refuse evaluation as MissingRequiredLootParameter before invoking a mod. A typed key cannot be constructed with an arbitrary conflicting T. Presence is explicit: an empty required tool is present, zero radius and false enchantment activity are present, and Unit presence differs from absence.
LootContext, proposed key/set descriptors, error enums and every new crossing snapshot explicitly use #[derive(Wire)] (the repository macro is horizon_mod_api_macros::Wire). Existing LootEntity, LootDamage, LootStack, LootPlace, LootLevel already derive it; BlockState also derives it. Use the existing API Tag representation and shared NBT codec. No engine types, Java objects, serde values as typed parameter payloads, arbitrary Any, or manual per-target encoding. Generate and validate native/wasm dispatch together.
Mods declare every parameter they read, required or optional, when registering a loot kind; a read outside the declaration is NotDeclared. Unknown built-in keys or deferred keys refuse registration with proposed UnsupportedLootParameter { key }; unsupported set families refuse as UnsupportedLootParameterSet { set }. A kind's required reads must be allowed by its declared sets, otherwise LootParameterSetMismatch. Optional reads may be absent, but unsupported capability must never masquerade as absence.
Defer custom keys and custom sets in this first build. Proposed UnsupportedCustomLootKey and UnsupportedCustomLootParameterSet are load-time refusals on both targets. Later, permit namespaced load-time declarations with a fixed API-owned Wire schema, unique owner, duplicate/type-conflict refusals, and explicit producer binding plus required/allowed set descriptors. Freeze resolved numeric slots before world use; no runtime string-map registration. The public docs' unregistered Java keys do not establish a safe cross-process type contract for Horizon.
Safety, cost and action boundaries
This view takes no mutable Host and cannot mutate world, save, run dynamic drops or recursively roll loot. Existing hooks remain their current moments; this is a value contract extension. Capture on the authoritative server before entering mod code and release engine borrows. Owned snapshots remain stable across nested evaluation. Scope restoration must hold on unwind.
Treat declarations, JSON and function output as untrusted. Resolve key/set identifiers at registration; reject type conflicts and invalid registry keys. Reuse shared Wire frame, allocation and NBT budgets cumulatively across the entire context, including equipment and vehicle chains; NBT container depth may reach vanilla's 512 limit, not a new smaller arbitrary cap. Before code, verify shared byte limits against largest legal vanilla instances and raise transport capacity where needed. Reject oversized frames explicitly as proposed LootContextBudgetExceeded, never truncate facts or report missing optional data. Origin/radius must be finite; radius nonnegative. Preserve signed i32 enchantment levels and candidate counts rather than imposing inventory materialization rules here. Function output remains staged and fully validated before one writeback; refused conversion leaves the candidate unchanged.
Proposed failure policy: a caught callback panic or wasm trap discards that callback's staged output, attributes the fault to the mod, and aborts this evaluation with a diagnostic; never partially apply it. Do not claim current runtime containment merely because dispatch tracks attribution. Condition defaults and function output must not turn a malformed required context into a successful roll. No new writes, retirement or journals originate in the view. Existing enclosing actions retain root-commit journal/retirement publication inside lifted passes and must refuse saves while an action savepoint is open.
No listener: retain the existing constant-time subscription guard before conversion; no parameter map allocation, serialization, NBT copy or additional RNG draw. With listeners, capture cost is proportional to declared captured values and encoded bytes; fixed resolved slots make reads O(1). Both evaluator guards and their seed-producing callees must be measured, not just the game adapter.
Acceptance matrix
Run every fixture through an actual custom condition and function on native and wasm and compare typed observations, not just codec round trips. Expected values below are proposed producer contracts.
| Fixture | Concrete expected observations |
|---|---|
| Adult zombie at (10,64,20), directly killed by player P using iron sword | ENTITY: this.kind=minecraft:zombie, baby=false; origin=[10,64,20]; player.kind=minecraft:player; attacker and direct attacker both P's captured facts; damage.kind=minecraft:player_attack. TOOL disallowed in ENTITY: do not infer sword from attacker equipment. Block/radius absent/disallowed. |
| Same zombie killed by P's arrow | Attacker=P; direct.kind=minecraft:arrow; damage.kind=minecraft:arrow; last_damage_player=P. |
| Zombie falls without player attribution | Required this, origin and damage (minecraft:fall) present; player/attacker/direct absent. |
| Stone at (2,70,4), bare-hand block roll | BLOCK origin=[2.5,70.5,4.5], block_state resolves minecraft:stone, TOOL present as empty instance; block_entity/radius absent. |
| Same block with explosion decay radius 4 | Radius=Some(4.0), never absent or zero substitution. |
| Broken named container | Saved block-entity Tag equals pre-removal metadata including custom name; subsequent removal does not alter snapshot. |
| FISHING roll with explicit stick and no entity | Origin equals supplied coordinates; TOOL item=minecraft:stick, count=1 and exact components; this absent. PR #2595 retains ownership of roll API semantics. |
| Deferred enchantment/trade/custom key registration | Exact UnsupportedLootParameter, UnsupportedLootParameterSet, or custom refusal; identical native/wasm result before evaluation. |
| Missing BLOCK state / false / Unit | Missing required state refuses before callback; future false bool returns Some(false); future trade token returns Some(()) versus None. |
| Malformed and excessive inputs | NaN/infinite origin, negative radius, oversized nested data refuse without callback or partial output; legal depth-512 data and largest vanilla fixture fit shared bounds. |
| Panic/trap; nested roll; no listeners | No partial candidate output; outer snapshot unchanged after nested scope; no-listener output and RNG sequence exactly match baseline with zero context conversions. |
Proposed build stages
- Verify exact vanilla required/allowed sets using REA initializer/lambda evidence; enumerate every gameplay producer of both evaluators. Reconcile the owner-supplied read list. File none in this docs-only change.
- Build the named BLOCK/ENTITY/FISHING subset and typed capability registration, replace obsolete snapshot fields, and regenerate both targets and crossing coverage in one PR. Add boundary fixtures before broadening producers.
- Implement deferred interaction/enchantment/trade projections only with verified producer routes and matching native/wasm fixtures. Design container and custom-key schemas separately before enabling registration.
Open questions
- Which additional facts do the owner-supplied reads actually require? Recommendation: retain the initial subset until the owner-supplied read list names its parameters and types; add only API-owned snapshots justified by those reads.
- What read-only projection should CONTAINER expose? Recommendation: defer rather than grant mutable slot authority or pretend a live SlotProvider is a serializable value.
- Which shared byte budget accommodates maximum legal tool/block-entity snapshots? Recommendation: establish measured vanilla capacity in stage one and reuse/increase the shared budget; do not introduce a smaller loot-only limit.
Design: resource-backed special item renderers
Provisional answer to #876: what resource-backed baked geometry and typed stack-derived state may a special renderer submit? Root must review this design before code. This page implements no API. Geometry belongs in resources; callbacks select and pose baked parts only where ordinary item-model properties cannot express the behavior.
Reference and boundary
Vanilla 26.3 SpecialModelRenderers.bootstrap registers type identifiers and
unbaked codecs. SpecialModelWrapper.update extracts an argument from the stack,
creates a layer, records extents, transform and foil, and installs the special
renderer plus argument. Verified using tools/rea_vanilla.py method for both
methods from /workspace/horizon. The callers query for the wrapper's update
returned zero direct references; interface dispatch means that is not a complete
context census. Horizon routes are listed below instead.
The named NeoForge counterpart is RegisterSpecialModelRendererEvent. Its
26.3 public Javadoc
was inaccessible during this investigation. Public client-item documentation
describes stack state preparation and submission, but currently labels itself
26.1. Exact 26.3 registration timing, cancellation, codec shape and capability
limits remain unverified. Require public 26.3 documentation before adopting
those details; the contract below is proposed Horizon policy, not asserted parity.
No foreign renderer source was read.
level-extracted-state.md is absent from arismoko/main at this worktree's base
ff35cdee95. PR #2357 is an
adjacent proposal only; this design neither copies it nor requires its contents.
Proposed registration, bake and preparation
One register_special_model_renderer client registration moment takes a
ClientHost and a typed registrar. Dependency-ordered listeners register a
namespaced type, bounded resource schema, declared stack-component inputs and
one preparation callback. Duplicate type ownership refuses registration with
both owners named; vanilla built-in identifiers cannot be replaced. Resolve
identifiers and callback slots at load/bake, not on every draw. No second event
bus or compatibility alias is introduced.
API-owned SpecialRendererRegistrationWire, SpecialResourceSchemaWire,
SpecialBakeRecipeWire, SpecialStackInputWire, SpecialDrawContextWire,
SpecialDrawListWire and SpecialPartInstanceWire define explicit integer
widths, enum tags and lengths. Generated native and wasm adapters share these
representations and validation; game JSON values, Rust engine structs, GPU
pointers and borrowed live stacks never cross the boundary. Wire is an API
boundary, not a network synchronization format.
Resources declare named geometry parts, texture/material references, local
transforms and conservative extents. Reuse existing model loading/baking and
prepared geometry ownership. Bake once per resource reload; callbacks cannot
create geometry, compile shaders, read arbitrary files or recursively render
items. UnsupportedSpecialRendererCapability refuses registration requesting
raw pipelines, live world access, arbitrary codecs, recursive item draws or
undeclared stack inputs. Unknown special types produce
UnknownSpecialRendererType with resource path and type, replacing the foreign
base-model workaround. This refusal applies only to special-model type decoding;
unrelated unknown identifiers keep their existing behavior. Built-in specials
retain their existing renderer paths. A failed candidate resource reload leaves
the previous complete resource set active; initial failure uses the ordinary
missing-model diagnostic rather than silently drawing base.
Client-lib decides preparation input; game fires the registered callback with
ClientHost and a typed drawing context; client-lib validates and applies in one
transaction. Input is an immutable copy of declared component values, display
context, owner snapshot, deterministic seed, pose, packed light and foil policy.
The drawing context records instances of this recipe's baked parts with local
poses and vanilla-space tint. It exposes no device, frame format or live entity.
The output is an immutable draw list stored in the ordinary item render state;
GUI and world consumers execute it without calling the mod again. Engine display
transforms, clipping, lighting, glint, sorting and bounds remain authoritative.
Selected parts' transformed bounds must fit declared conservative extents.
Apply rechecks the captured stack revision, resource holder and mod lifetime; changed preconditions refuse the prepared result, never recompute within apply. A later ordinary preparation may retry. Preparation writes and staged draws roll back together. Inside lifted passes, publication, retirement and journal emission wait for root commit; rollback publishes nothing. Saves are refused during an open action savepoint under the existing transaction rule, even though rendering itself adds no persistent state.
Lifetime and colour
Use the existing resource lifecycle holder and its identity for baked references and leases, with resolved part slots. Do not add a special-renderer generation counter. Reload builds a complete candidate, validates/bakes it, then publishes atomically at root commit. Old queued states hold leases until submission ends; they cannot publish into the replacement holder. Retire resources after leases drain. Unload stops new callbacks, drains outstanding callback/draw leases, then unloads native code or wasm stores. Device recreation rebuilds engine GPU objects from resource data without exposing their identity to mods.
Follow mod render colour. Packed ARGB tint and light 0..=15 remain vanilla-space inputs, converted once by the engine. Shader colours are linear BT.709, paper white 1.0, values above 1 allowed. Generated entry/exit boundaries own storage conversion. Texture data channels stay data; coverage does not grant ownership of frame alpha. Shader-pack and SDR/HDR changes preserve this contract and cannot make a callback depend on the frame format.
Current route owners
Verified against base ff35cdee95; paths are relative to crates/. These are
integration owners, not claims that the proposed callback already exists.
| Route | File:line | Required behavior |
|---|---|---|
| Decode / foreign workaround | horizon-assets/src/item_model/special.rs:82, :90, :103 | Resolve registered types; report unknown special types |
| Resource parts and baked extents | horizon-client/src/item_render/special.rs:51, :307; horizon-client/src/item_render.rs:748, :841 | Bake resource recipe and conservative bounds |
| Shared stack preparation, hands | horizon-client/src/game/interface/item_draw.rs:376, :398 | Freeze declared inputs and prepare once per resolution |
| Inventory, cursor and other GUI icons | horizon-client/src/game/interface/inventory_slots.rs:6; horizon-client/src/game/interface/item_draw.rs:455; horizon-client/src/item_render/gui.rs:180 | Same prepared parts, GUI clipping and lighting |
| Dropped/ground items | horizon-client/src/game/interface/item_draw.rs:705, :789, :793 | Copy poses reuse state on GPU and CPU paths |
| Frames and armor-stand equipment | horizon-client/src/game/entities/frames.rs:749; horizon-client/src/game/entities/armor_stands.rs:1786, :1847 | Fixed/head/hand contexts use same preparation |
| Other world item consumers | horizon-client/src/game/blocks/campfire.rs:172; blocks/vault.rs:250, blocks/shelf.rs:230, blocks/brush.rs:347 under the same game/ directory | Preserve context and engine lighting |
| Entity/effect and mod-submitted items | horizon-client/src/game/items/potions.rs:2219, items/totem.rs:224, items/item_particles.rs:18; horizon-client/src/game/modding/native_block_entity_draw.rs:1237 | Share resolver, no recursive callback escape |
| CPU special submission / GPU submission | horizon-client/src/item_render.rs:267, :299; horizon-client/src/item_render/special.rs:466; horizon-client/src/game/interface/item_draw.rs:556 | Consume identical frozen draw list |
| Reload and GPU resource ownership | horizon-client/src/game.rs:14259; horizon-client-render/src/gfx/prepared_model.rs:256 | Rebuild affected item states and retain leased resources |
Native and wasm need generated catalog registration, dispatch and callback exports in the eventual implementation; neither adapter exists for this feature today. Both must enter the same game preparation boundary and enforce identical failures. The implementation review must trace first/third-person callers through the shared hand resolver and reload publication through its full owner chain before claiming exhaustive integration.
Untrusted inputs and cost
Proposed extension budgets: 256 parts per recipe, 65536 vertices per part, 16 MiB decoded geometry per recipe, 64 KiB resource parameters and 16 KiB copied stack inputs, 256 instances and 64 KiB encoded draw output per preparation. Use checked arithmetic before allocation; bound encoded and decoded sizes, resource nesting to 32 and identifiers to 256 UTF-8 bytes. Reject invalid tags, indices, foreign/stale part slots and dependency cycles. Transforms and extents must be finite, local translation magnitude at most 1024 and scale magnitude at most 64; zero scale is valid. These extension budgets do not cap vanilla's own models, stack components or draw capacity. Oversized declared stack input refuses this extension preparation without truncating the actual stack.
Catch unwind-capable native callback panics; wasm traps and fuel exhaustion have the same policy: discard the entire draw list and callback transaction, diagnose once and disable the registration for the holder lifetime. Draw the missing-model marker for affected custom layers, retaining independent built-in layers. A later built-in item still renders normally. Native aborts cannot be caught and native callbacks cannot safely be forcibly preempted; wasm gets finite execution fuel. Reject an invalid command atomically, never retain a partially accepted list.
With no registrations, retain the existing built-in branch: one O(1) resolved variant test, zero extension allocations, serialization or listener scans. A registered draw dispatches directly by resolved slot, copies only declared inputs and records bounded commands. GPU/CPU submission has no callback or mod scan. These are performance acceptance requirements, not measured results.
Acceptance matrix
Future implementation tests use original assets, not third-party model files.
| Case | Concrete expected result |
|---|---|
| Original cube A and two separated cubes B; boolean component false/true | Exactly 6/12 resource quads selected in both inventory and hand |
| Same true stack in ground, frame, head and other-player hand | B selected in all four; engine context transforms differ |
| Resource reload changes B to three cubes | True stack selects 18 quads after commit; 0 new draws reference old holder |
| Reload fails with unknown special type | Named error; previous holder remains active; initial load displays missing model |
| Queue old state, reload, drain | Old lease valid until drain; retirement occurs once after root commit |
| Rollback lifted preparation/reload | 0 published draws, retirements or journal entries |
| Save during action savepoint | Refused; no persisted partial state |
| NaN transform, 257 instances, stale part, oversized input | Entire custom layer refused; 0 partial instances |
| Native panic / wasm trap followed by vanilla cube | Custom layer missing marker; vanilla cube still 6 quads |
| Unsupported raw-pipeline capability / duplicate registration | Named registration refusal; no partial type installation |
| White 1, linear RGB 2, encoded tint 0.5 | Shader values 1, 2, approximately 0.21763764; frame alpha engine-owned |
| Native and wasm, same inputs | Identical part slots, transforms, tint and refusal outcomes |
| No listener, repeated built-in scenes | Identical baseline output; 0 extension callbacks/allocations |
Proposed stages (file none)
- Review this contract and obtain public NeoForge 26.3 registration evidence.
- Finalize API-owned Wire schemas, generated native/wasm boundary and refusal diagnostics; connect resource decode/bake to existing lifecycle ownership.
- Integrate shared stack preparation and both submission paths, transactional reload/teardown and colour rules; trace all caller routes above.
- Add original-asset acceptance scenes, failure injection and empty-registration counters, then measure cost. No implementation files are proposed in this note.
Open questions
- Exact NeoForge 26.3 registration contract is unverified. Recommendation: require public 26.3 docs/Javadoc before locking the counterpart's signature and timing; do not infer it from older docs or source.
- R2 prepared-part API and practical extension budgets need implementation review. Recommendation: use the existing lifecycle holder with the budgets above, test representative original scenes, and enlarge only extension limits with measured evidence; preserve vanilla capacity throughout.
Design: section geometry
Provisional answer to #864: geometry added by a mod belongs to one section compilation, replaces its previous contribution on remesh, and dies with that section's lifetime. Ari's side must post the decision on the issue before implementation. No Rust API is added here.
Section meshing already proposes the worker capability, Wire geometry, budgets, epochs and teardown needed here. Extend that contract; do not create another context, store, callback bus or publication boundary. #1508's returned comments contain only a claim, so approval is not verified. This note adds the section-level append case rather than repeating its recipes.
Vanilla and NeoForge 26.3
Verified from /workspace/horizon with python3 tools/rea_vanilla.py method net.minecraft.client.renderer.chunk.SectionCompiler compile and the matching
callers command: SectionRenderDispatcher.RenderSection.CompileTask.doTask
is the sole reported caller (complete coverage). Compilation traverses 4096
positions, gathers block entities separately, emits fluid and model quads into
material layers, then sorts translucent output and resolves visibility. There
is no vanilla mod contribution event. These observations do not establish a
NeoForge event insertion point.
The issue names AddSectionGeometryEvent, distinct from per-frame
SubmitCustomGeometryEvent. The public 26.3 javadoc
was inaccessible during this audit. Exact event methods, timing, threading and
cancellation semantics remain unverified. Public model documentation
was reachable but labelled 26.1; it does not verify this 26.3 event. Do not claim
full counterpart parity or freeze the public signature until that gap closes.
No NeoForge source/patches or other project's source were consulted.
Proposed contract
Use add_section_geometry as the section append capability of the existing
frozen-plan preparation contract. Owner-thread preparation gets ClientHost,
resolves subscriptions and captures immutable inputs in decide/fire/apply order;
apply refuses changed preconditions. Ordinary mod code runs only there. A declared
worker-safe registration may instead emit one bounded batch per section using
the existing restricted worker Host. No Game, live world, mutable attachments,
scheduling, drawing or GPU access is available on workers. Dependency ordering
is identical for native and wasm; native captures are immutable and thread safe,
wasm uses isolated worker instances and bounded fuel. Both use the same API-owned
Wire values and resource keys. No borrowed engine structures cross the boundary.
Bind the batch before the block loop and merge its validated concrete geometry into ordinary section layers, including empty-air sections (the current early return must not discard additions). No per-cell callback or listener lookup. Append never replaces block recipes, changes visibility or suppresses fluids. Per-cell additions and model replacement remain #1508 recipes. The same quad must be submitted through exactly one route. Translucent additions enter terrain sorting; materials, prepared-model identities and colour follow section-meshing.
#2333's #772 proposal freezes section and cell-face visibility from the same raw snapshot before dispatch. Consume that decision once; do not ask visibility again inside this callback. Recommend section hiding suppress all terrain contributions, while cell-face masks affect only additions explicitly attached to that cell/face. Unattached decorative quads remain unculled. Block-entity drawing retains its separate R2 lifecycle decision. Existing state visibility is reused, not redispatched.
Each job owns a lease on its frozen plan, callback instance and resource epoch. Keep section lifetime/revision, halo revisions, resource/tint/mod generations in the same result token used by #1508. Check again immediately before upload. A resource-A job may finish after B but publishes zero A output. Coordinate reuse gets a new lifetime. Cancellation is best effort; unloading removes the mesh and prevents every outstanding result publishing. Remesh replaces the complete old mesh, never appends to it. Teardown stops admission, increments generation, cancels and drains jobs/results, then releases native code/wasm stores/resources.
Treat input and output as untrusted. Inherit #1508's 262144 quads/section, 32 MiB encoded section data and 128 MiB in-flight limits, checked before allocation; cell-attached output also obeys 256 quads/cell and block-local [-1,2]. Recommend unattached positions stay within section-local [0,16] on each axis so section bounds remain sound. Validate decoded sizes, indices, tags, generation keys and all finite vertex attributes; inherit UV magnitude <=65536, normal components [-1,1], alpha [0,1]. NaN/infinity anywhere refuses the entire offending batch. No partial append survives. Native unwind panics and wasm traps/fuel exhaustion refuse that registration's batch, disable it for the generation with one diagnostic, and invalidate affected sections; built-in geometry remains. Native aborts cannot be recovered, and native execution cannot be safely forcibly preempted.
With no subscribers select the existing mesher at dispatch: zero extension allocations, halo copies, callback calls or worker hot-path dispatch. Resolve the subscriber presence flag once, O(1); do not scan mods. Performance is proposed, not measured here; inherit the under-1% benchmark-noise target from #1508.
Verified route owners
Paths relative to crates/, audited at ee5816229c. These are existing
integration points, not claims that proposed guards already exist. Shared
invalidation and publication rules above apply to every row.
| Route | Current file:line owner | Required extension |
|---|---|---|
| Initial mesh and remesh admission | horizon-client-render/src/streaming.rs:2237 | Freeze one shared plan before dispatch |
| Worker execution | horizon-client-render/src/streaming.rs:1142; horizon-client-render/src/mesher.rs:1751 | Batch once; include empty sections |
| Block writes and border edits | horizon-client-render/src/world.rs:385 | Invalidate shared halo dependencies |
| Remote chunk arrival | horizon-client-render/src/streaming.rs:1302 | Capture replacement section and neighbour revisions |
| Biome/light replacement | horizon-client-render/src/streaming.rs:3455, :1342 | Rebuild dependent plans |
| Resource and visibility refresh | horizon-client-render/src/streaming.rs:1277; horizon-client/src/game/modding/native_render_visibility.rs:13 | Replace context and invalidate through one route |
| Unload | horizon-client-render/src/streaming.rs:516 | New lifetime on reload; remove mesh, cancel old jobs |
| Abandoned/stale completion | horizon-client-render/src/streaming.rs:1887 | Extend current sequence/column refusal with shared epoch token through upload |
Acceptance matrix
Future implementation expectations; no runtime tests were run for this note.
| Case | Concrete expected value |
|---|---|
| Empty section plus one decorative quad | 1 quad after build and after each of 3 remeshes |
| Isolated cube plus one quad | 7 quads; callback executes once per compilation |
| Unload then late completion | 0 uploads and 0 retained section draws |
| A finishes after resource B | 0 A uploads; B's one-quad contribution remains 1 |
| Same coordinates, new lifetime | Old result uploads 0 times |
| Section hidden by shared visibility | 0 terrain quads, 0 visibility redispatches |
| NaN, infinity, 262145 quads, invalid material, native panic or wasm trap | 0 offending added quads; built-in cube retains 6 |
| Native/wasm same bounded input | Same vertex values, materials and quad count |
| No listeners | Baseline mesh identical; 0 extension allocations/callbacks |
Proposed stages (file none)
- Ari decides the shared append/visibility contract on #864 and coordinates #1508/#772; verify the exact public 26.3 counterpart before naming the API final.
- After #1508's R2 prerequisites, implement shared job leases, budgets and publication guards for both targets together.
- Add section batch adapters and a test mod; prove the matrix, unload/reload races and no-listener counters before performance measurement.
Open questions
- Does #1508 subsume #864? Recommend yes for lifetime and execution; retain only this section-level append capability and its test-mod acceptance case.
- Should hidden sections hide additions? Recommend yes for terrain; decide once with #772, preserving separate block-entity lifecycle policy.
- May unattached geometry cross section bounds? Recommend no initially: [0,16] keeps culling and unload ownership explicit. Cell-attached geometry retains #1508's halo contract.
- Exact NeoForge counterpart contract remains unavailable. Recommend obtain public 26.3 javadoc evidence before implementation approval; do not infer semantics from older docs.
Design: section meshing
Proposal for #1508, requiring maintainer approval before code. Implementation follows R2 GPU instancing (#808); this page does not claim that prerequisite is complete. No Rust API is implemented here.
Scope and reference
Extend terrain geometry preparation, not the renderer. Full mesher replacement is out of scope: replacing scheduling, visibility, buffers and upload ownership would defeat the frozen-plan and shader contracts. Renderer ports can use this contribution seam but cannot install an arbitrary section compiler through it. Fluids retain their existing mesher; replacing block geometry does not remove fluid geometry. Block entities drawing themselves use the R2 lifecycle decision, not a second geometry hook at the same moment.
Vanilla 26.3 SectionCompiler.compile traverses the section, collects block
entities separately, emits fluid and model geometry into material layers, and
sorts translucent quads. Verified with python3 tools/rea_vanilla.py method net.minecraft.client.renderer.chunk.SectionCompiler compile from
/workspace/horizon; its callers are queried with the corresponding callers
command. The proposed frozen plan is Horizon policy, not a vanilla hook.
The issue identifies only a partial NeoForge counterpart: model data and model
quad hooks, not whole-section replacement. Public
model documentation
is contextual reference, not a claim of an identical event contract. No external
project implementation is a design input.
Owning thread and frozen decisions
Register subscriptions by block or exact state during client registration;
resolve keys to dense state slots once per resource epoch. Exact-state rules
follow block rules, listeners in dependency order. One client-side preparation
moment receives a ClientHost and immutable section input on the world-owning
thread. Client-lib decides, game fires, client-lib validates and applies in one
transaction; if section or resource preconditions changed, refuse and reschedule,
never silently recompute inside apply. No new broad Host service is needed.
Freeze a section plan with shared state recipes and sparse position overrides for neighbour-dependent choices. Resolve model selection and culling there. Workers consume immutable arrays and prepared geometry references, never a live world, game handle or ordinary mod callback. A state recipe selects a prepared model, appends quads, or replaces its block-model quads (including with zero). Culling is a six-face mask plus explicit full-face occlusion declarations; unculled decorative quads are permitted. Replacement clears the original geometry before append. Multiple appenders compose; reject competing replacements for the same state at registration with both owners named. Do not let load order silently choose a replacement.
Snapshot the 16-cubed section plus a one-block halo on all six sides, including
edges and corners: 18 cubed = 5832 state cells, with immutable light, biome and
availability inputs. Model selection may inspect only that radius. Missing
neighbours are explicitly Missing, never a fabricated state id; for selection
and culling treat them as nonmatching, nonoccluding air. Outside world height is
known air. Arrival or disappearance invalidates every dependent border section.
Larger-radius connected textures are out of scope. Freeze deterministic position
seeds; worker order cannot change model choice. Biome blending may use the
engine's separately captured blend footprint; it does not enlarge mod geometry
neighbour access.
API-owned data and worker execution
Proposed API-owned Wire representations are SectionMeshPlanWire,
SectionMeshInputWire, StateMeshRecipeWire, MeshQuadWire, MeshVertexWire,
MeshFaceMaskWire, PreparedModelKeyWire, MeshMaterialKeyWire, and
WorkerMeshRegistrationWire. These are bounded values with explicit integer
widths, enum tags and lengths; no game structs, GPU layouts, pointers or borrowed
references cross native/wasm boundaries. Model and material keys carry resource
generation and resolved slots; unknown or stale keys refuse that contribution.
Wire here is the mod boundary, not a multiplayer packet.
A declared worker-safe registration is a separate execution capability within this preparation contract, not another all-mod event bus. Give it a restricted read-only worker Host over captured input and registries, with no world writes, attachments mutation, scheduling, drawing or live Game access. Native captures must be immutable and thread safe; wasm uses isolated worker instances with the same imports, budgets and dependency ordering. Bind callbacks once into batches of subscribed states/positions. Geometry callbacks emit bounded section batches before the ordinary block loop; that loop reads concrete data only, with no per-block trait objects, wasm calls or dynamic listener lookup.
#2305's biome colour resolvers already require worker colour execution. Preserve that declared worker-safe capability: capture resolver state and lifetime with the job, use the existing tint evaluation/cache path, and forbid live mutable world access. Its colour callbacks are not implicitly geometry callbacks. The no-per-block-dispatch requirement applies to this new geometry seam; it does not assert existing tint callbacks never execute. Owner-prepared tint values remain valid where supported. Teardown first stops scheduling and increments mod generation, then cancels jobs, drains callback instances and results, releases plans, and only then unloads native code or wasm stores. A job lease prevents calling unloaded code.
Rendering and colour ownership
Use R2b prepared model geometry as the canonical baked source, sharing immutable geometry and material identities instead of exposing atlas offsets or GPU device objects. Section emission expands that geometry through the terrain path; instanced self-drawing block entities remain on R2. Freeze the self-draw skip bit before terrain preparation: it suppresses that entity's ordinary block-model geometry once, without suppressing unrelated extra geometry or fluids. Never infer self-drawing from merely having a block entity.
Opaque and cutout output retain terrain depth/culling policy. Translucent output enters the existing translucent sorting and upload path, never the opaque buffer because a mod requested an arbitrary shader. Cull only declared full occluding faces; translucent neighbours do not acquire opaque occlusion automatically. Shader packs consume the same material attributes as vanilla terrain; a mod cannot choose a raw pipeline, blend mode or frame format here.
Follow #1498 and the colour design: shader API 0.2 colour is linear light with BT.709 primaries, paper white 1.0, and values above 1.0 retained. Existing packed ARGB tint/light inputs remain vanilla-space inputs; renderer conversion happens once. Encoded tint 0.5 becomes approximately 0.21763764 under power 2.2; linear shader output 2.0 stays 2.0 until output mapping. UV, normals, depth and masks remain data. Alpha and compositing belong to the renderer. Pack changes rebuild plans only when geometry/material/colour interpretation changes; render-only changes rebuild pipelines without needlessly re-running geometry callbacks.
Route ownership and invalidation
Verified owners at worktree base 5c2583c030; paths below are relative to
crates/. These are current integration points, not claims that proposed epoch
checks or callbacks already exist.
| Route | Current file:line owner | Required design behavior |
|---|---|---|
| Initial dispatch and worker mesh | horizon-client-render/src/streaming.rs:2237, :1142; horizon-client-render/src/mesher.rs:1741, :1751 | Capture plan before dispatch; mesh immutable snapshot |
| Block/state write, neighbours, borders | horizon-client-render/src/world.rs:385 | Dirty all sections intersecting radius-one dependency, including vertical and diagonal borders |
| Chunk arrival, biome and light updates | horizon-client-render/src/streaming.rs:3455, :3466, :3476; :1302, :1341 | Invalidate dependent halo plans even if centre states did not change |
| Unload | horizon-client-render/src/streaming.rs:516, :1394, :1961 | Invalidate lifetime generation, cancel jobs, remove uploads; dirty surviving neighbours |
| Resource/model replacement | horizon-client-render/src/streaming.rs:1277, :1283; horizon-client/src/game.rs:14161 | Atomically replace WorkerContext and prepared geometry epoch; rebuild loaded sections |
| Biome-tint refresh | horizon-client/src/game/modding/native_biome_colors.rs:16 | Capture new colours/resolvers and remesh through resource reload |
| Shader/renderer rebuild and pending uploads | horizon-client/src/game.rs:14161, :14178 | Clear pending old output; rebuild affected plans through shared invalidation |
| Cancelled/stale completion | horizon-client-render/src/streaming.rs:1887 | Refuse results with mismatching token or absent column; recycle buffers |
| Prepared geometry generation | horizon-client-render/src/gfx/prepared_model.rs:268, :273 | Keep old resources leased while jobs run; refuse old-generation publication |
| Block entities and layer emission | horizon-client-render/src/mesher.rs:1751; vanilla SectionCompiler.compile | Integrate proposed R2 skip decision here; retain separate self-draw lifecycle and translucent sorting |
| Native/wasm tint and teardown | horizon-client-render/src/mesher/mod_tint.rs:20, :110 | Extend captured data/lifetime boundary; proposed geometry adapters belong in game, not render crate |
Every job/result carries section lifetime, section revision, halo revisions, resource epoch, tint generation and mod generation. Publication checks all of these again immediately before upload. Unload/reload at identical coordinates creates a new lifetime. Cancellation is best effort; even a completed cancelled job cannot publish. Old epoch A resources stay alive for A jobs but their results cannot replace epoch B. Snapshot leases and result memory count against admission budgets until dropped; never reuse an old plan against newly baked geometry.
Bounds and performance
Proposed hard limits: 256 contributed quads per block position, 262144 contributed quads and 32 MiB encoded contribution/plan data per section, and 128 MiB total in-flight extension data. Count shared recipe storage once globally and expanded output per job; checked arithmetic precedes allocation. Pause new extension jobs at the global limit, rather than evicting live leases. Validate every vertex, UV, normal and colour for finiteness; positions must be in block-local [-1,2], UV magnitude at most 65536, normal components in [-1,1], and alpha in [0,1]. Geometry cannot extend beyond the halo. Reject invalid indices, material keys, lengths and enum tags before baking. Enforce both encoded and decoded budgets.
Refuse a failed mod's entire section contribution atomically and use original built-in geometry, including original culling, without partial replacement. Catch unwind-capable native panics at the adapter; wasm traps/fuel exhaustion have the same refusal. Disable the faulty registration for the generation with one diagnostic, invalidate affected sections and continue built-in rendering. Native aborts cannot be recovered; worker-safe native code must use the supported unwind boundary. Wasm batches receive a finite fuel quota; native callbacks have no safe forced preemption and remain subject to the declared worker-safe contract.
With no geometry subscriptions, use the existing mesher entry directly: no plan allocation, halo copy, listener scan or per-block branch added by this feature. Select the path once at dispatch. With subscriptions, share dense state recipes, reuse scratch buffers and precompute sparse overrides. Acceptance requires allocation/callback counters at zero for the empty extension path and measured no-mod meshing throughput within benchmark noise (target under 1% regression); this document makes no measured performance claim.
Acceptance cases
These are future implementation tests, not results of this documentation change.
| Input | Expected value |
|---|---|
| Isolated opaque unit cube | 6 quads |
| Two adjacent full opaque cubes | 10 exterior quads |
| Replace isolated cube with empty recipe | 0 model quads, no retained original six |
| Append one unculled quad to isolated cube | 7 quads |
| Two cubes across section border; neighbour unloads | Centre contribution changes from 5 faces to 6; reappearance returns to 5 |
| Connected model on east/west matches | Neither: model 10; east only: 11; west only: 12; both: 13; Missing counts as false |
| Write at local (15,15,15) with surrounding sections loaded | Exactly 8 radius-one dependent sections dirtied |
| Finish A after resource B or unload/reload | 0 A uploads; B remains visible |
| Self-drawing block entity with six-face model | 0 ordinary model faces; one R2 draw; fluid retained |
| Two translucent quads at distances 2 and 5 | Both in translucent output, distance 5 ordered before 2 |
| NaN vertex, 257 quads at one cell, panic or wasm trap | Whole offending contribution refused; isolated built-in cube has 6 faces |
| Reload during worker colour callback | Old callback lease survives to completion; old result has 0 uploads |
| Native and wasm same bounded plan | Identical model ids, face counts and materials |
| Repeated empty-listener runs | Identical baseline geometry; 0 extension allocations/callbacks |
Open questions
- Final R2b geometry identity and R2 self-draw extraction type are still moving. Recommend binding the proposed keys/skip bit to those approved types after #808, retaining the generation and lifetime rules above.
- Limits need representative mod scenes before implementation approval. Recommend the explicit bounds above as initial ceilings, then measure dense connected geometry and wasm batches before increasing them; do not ship an unbounded escape hatch.
Design: snapshot participants
Provisional proposal for #999, not an implemented guarantee. Named question: what native-and-wasm participant contract can preserve synchronous rollback given reentry?
Recommend runtime-owned transactional participant values, with generated typed access, and callback-free rollback. Arbitrary mutable guest fields with snapshot/restore callbacks remain unsupported until the runtime can prove equivalent guarantees on both targets. An exported participant resource alone does not solve reentry. This recommendation narrows the requested arbitrary-state contract and requires maintainer approval before implementation.
Evidence and counterpart limits
The issue body and all three comments were read, including Ari's release explanation and our claim. The original same-group reentry diagnosis is historical: crates/horizon-mod-wasm/src/runtime.rs:536 now admits resumed synchronous calls at :557, but :547 refuses guest execution during native panic. Normal reentry is therefore insufficient evidence for unwind restoration. The component-model boundary discussed historically in #1903 is already gone; generated shared-memory Wire is the current boundary. Nothing here requires WIT, lifting/lowering, a particular store API, guest pointers, or a particular memory layout.
Vanilla has no Horizon participant interface. The pinned 26.3 ItemStack.copy bytecode was requested with python3 tools/rea_vanilla.py method net.minecraft.world.item.ItemStack copy; it returns EMPTY for empty stacks, otherwise copies count and components and preserves pop time. Stack copying cannot establish mod participant callback ordering.
The public 26.3 SnapshotJournal Javadoc and transaction documentation URL were inaccessible. NeoForge 26.3 snapshot release, child merge, close callback and root notification ordering are unverified. The issue requests first-write snapshots per depth, abort including unwind, child merge, and NeoForge release/root notification ordering; these are requirements, not independently verified counterpart facts. Retrieve public SnapshotJournal and TransactionContext documentation before adopting an exact callback contract. No NeoForge source or patches were consulted.
Routes and existing owners
References are repository-relative at this worktree's HEAD. They identify current integration owners, not completed participant support.
| Route | Verified owner | Proposed treatment |
|---|---|---|
| Native closure transaction, nested success/error and unwind | crates/horizon-mod-vocab/src/item/transaction.rs:44 and :72; Drop at :82 | Preserve synchronous closure semantics and the guard; rollback must finish before a catcher observes state. |
| Game-owned open/close and nested merge | crates/horizon-client/src/game/runtime/transactions.rs:174, :179, :192 | Extend this coordinator's Level (:34), never add a mod journal coordinator. |
| Emergency abort | crates/horizon-client/src/game/runtime/transactions.rs:239 | Restore participant values through the same rollback path. |
| Root effects and rollback | crates/horizon-client/src/game/runtime/transactions.rs:462, :513 | Integrate participant lifetime and notification work here; no guest dispatch from rollback. |
| First-write storage examples | crates/horizon-client/src/game/runtime/transactions.rs:246, :257, :290, :303, :325, :338, :357, :388 | Existing block entities, attachments, player/menu/entity inventories, equipment, item entities and blocks retain their existing snapshots. Participant limits must not constrain these vanilla stores. |
| Native/wasm handler dispatch while transaction remains active | crates/horizon-mod-wasm/src/runtime.rs:536, :547, :557 | Validate the same participant operations on both targets; never infer unwind support from ordinary resumed dispatch. |
| Existing holder identity | crates/horizon-mod-runtime/src/attachments.rs:66; block-entity incarnation capture at crates/horizon-client/src/game/runtime/transactions.rs:66 | Bind holder-backed participants to existing lifecycle identity; no second generation counter. |
These routes cover transaction closure entry/exit, nested merge, emergency abort and deferred effects. Individual transfer callers continue using those shared routes. A code stage must audit generated imports and all unwind/trap containment callers against the then-current runtime before claiming complete native/wasm coverage.
Proposed contract
Register server-side participant declarations during the existing registration phase. Specify one typed declaration and holder-bound access surface, rather than another Host state table. Crossing types use the single explicit derive(Wire) mechanism with generated native/wasm parity; generated access adapters must reuse it and must not introduce a second serializer. Refuse registration with the proposed named error UnsupportedParticipantCapability, identifying the declaration, type and missing capability (bounded owned value, infallible runtime restore, or boundary representation). A persistence codec does not establish Wire support. Do not expose a native-only escape hatch.
A participant key combines its declaration and existing lifecycle holder identity; a retained handle does not pin its holder. Validate owner, server side, world and incarnation on each access. World close invalidates handles. Holder retirement inside a lifted action pass waits for root commit; aborted removal restores the existing binding. Session-owned state binds to the existing mod lifecycle owner, with no independent identity generation.
The runtime owns the authoritative value. Reads return owned typed values; checked replacement requires the borrowed Transaction and journals the old value before mutation. No mutable reference or guest pointer escapes. A mod may cache a read but cannot treat that cache or an arbitrary static field as transactional state. The first surface provides checked replacement only, with no edit closure or recursive-edit busy flag. Read/compute/replace must not hold a busy flag across unrelated dispatch; no mutable borrow crosses dispatch. Snapshot/restore/release perform runtime-owned moves or swaps, without mod codecs, destructors or callbacks during unwind. Allocation and encoding happen before publication; validation failure leaves the old value intact.
At each depth retain exactly one entry value per touched participant. Child abort restores its entry value. Child commit transfers its snapshot if the parent lacks one; otherwise the parent's earlier snapshot survives and the redundant child snapshot is released. Root abort restores all values and drops pending notices. Root commit finalizes values, holder retirement and journal emission in the existing coordinator, including lifted passes. Saves during any open action savepoint refuse with the proposed ActionSavepointOpen; they must never serialize tentative values.
Propose one root notice per changed participant, queued after durable in-memory commit, never during rollback. The notice receives the existing ServerHost and committed values; it cannot veto the completed transaction. Require deterministic notice ordering, specified and covered by independent fixtures before implementation. Freeze the committed notice batch and its committed-value payloads before delivery. A notice mutation requests a fresh action queued until the entire batch has been delivered, so it cannot disturb that batch. Exact release versus notice ordering and the deterministic inter-participant order remain gated on public NeoForge documentation. Do not advertise counterpart parity or ship arbitrary ordering under the counterpart's name.
Native unwind and wasm trap abort touched game and participant values without executing guest restore code. Contain snapshot preparation failure before mutation. Notice panic/trap is reported through runtime failure policy after commit; it cannot roll committed state back. Do not retry a partially executed notice. Process-aborting panics are outside recoverable unwind guarantees. If arbitrary guest fields are required, registration must refuse them until a reviewed runtime design supports both normal same-group callbacks and failure-time restoration; swallowing a failed restore or returning a default is unacceptable.
Treat all boundary data as untrusted. Validate declaration/schema, handle ownership, length arithmetic, depth and total retained bytes before mutation. Proposed initial mod-only limits: 64 KiB per encoded value, 64 nesting depths and 4 MiB retained participant snapshots per root; refuse with ParticipantBudgetExceeded, never truncate. These are provisional recommendations requiring approval against representative provider sizes and do not cap existing vanilla inventories/components. Check all budgets before publication of a replacement; a refused nested write preserves its immediate pre-write state, which may differ from the root-entry value. Native validates the same bounds. Resolve declarations at load; when unused, add only an O(1) empty participant-map check at transaction close, with no registration scans, per-tick allocation or serialization. Used cost scales with touched values and retained bytes. Wasm owned reads and replacements encode and copy data proportional to value size. Before approving limits, measure those costs on representative providers and account separately for retained snapshot bytes, live values, decoded temporaries and pending-notice payloads; the snapshot budget alone does not bound total memory.
Acceptance matrix
Proposed future tests, on both native and wasm; these are not runtime tests run for this note.
| Fixture | Concrete expected result |
|---|---|
| Initial 1; outer writes 2; child writes 3 and commits; outer aborts | Value 1; notices 0. |
| Initial 1; outer writes 2; child writes 3 and aborts | Value 2 inside outer; root abort yields 1. |
| Same sequence, both commit | Value 3; root notices 1; no child notice. |
| Two writes 1→2→4 at one depth | Snapshot count 1; abort restores 1. |
| Parent untouched; child 1→3 commits; parent aborts | Value 1, proving snapshot ownership transfer. |
| Participant 1→2 and inventory count 8→5, then native panic / guest trap | Participant 1, inventory 8, notices 0, guest restore calls 0 before catch resumes. |
| Snapshot encoding fails before proposed 2 | Value 1, snapshot count 0, explicit error. |
| Root notice traps after commit to 3 | Value 3, notice attempts 1, no retry or rollback. |
| Payload 65,537 bytes; depth 65; retained budget >4 MiB | Each refuses before its mutation; immediate pre-write value unchanged, including tentative parent or earlier child writes. |
| Save during open action; remove holder then abort | Save refuses; original holder binding survives; successor cannot be reached through stale handles. |
| Unsupported arbitrary guest field | Named capability refusal; no partial write. |
| Read then unrelated dispatch then checked replacement | No busy flag spans dispatch; replacement validates the current holder incarnation. |
| Notice A requests a fresh action while B remains pending | B receives its frozen committed payload before the fresh action runs; deterministic batch order. |
| No participants registered | Existing transfer outcomes unchanged; participant allocations 0, callback calls 0. |
Proposed stages
Filed stages: none. First verify public 26.3 ordering and approve the narrowed state ownership contract and limits. Then implement one runtime/coordinator slice proving callback-free unwind/trap rollback on both targets. Finally expose generated typed declarations/access, update the coordinator's no-mod-state header and #41 restriction, catalog, docs and sample together, and run the matrix. No stage may export native-only participants or depend on the current wasm transport implementation.
Open questions
- Can Portizon's providers use runtime-owned values? Recommend this as the first contract; arbitrary guest state remains refused. Require a representative translated provider before claiming the arbitrary-state request fulfilled. If translation requires live fields, commission the runtime design before claiming #999 implemented.
- What exact ordering does NeoForge 26.3 promise? Recommend obtaining public SnapshotJournal/TransactionContext documentation and encoding its release/root-notice trace as independent fixtures before code. Current proposed ordering is not certified equivalent.
- Which budgets are acceptable? Recommend the mod-only limits above with explicit registration/runtime errors and no reduction of vanilla capacity; approve using real translated provider sizes.
Design: stale handle errors
Provisional answer to #1644: typed stale errors for ordinary handle reads and writes. This question is small enough for an issue comment proposing the answer, as Ari permits; this short page records that proposal for review, not implemented behavior.
Proposed contract
Reuse the decide, fire, apply outcome vocabulary and Part instance state identity rules. Ordinary reads return owned values or a typed refusal; writes return their operation-specific success or typed refusal. Do not expose decision tokens or apply outcomes as a second transaction API. Replace covered Option/bool methods and their ID helpers together, updating catalog, generated adapters, callers and horizon-machine-sample without compatibility aliases. A valid domain absence remains an optional success value where the operation actually permits absence.
Resolve identity through the existing lifecycle holder and authoritative table: server-wide epoch plus id for entities, no dimension and no second generation. Missing means Stale(reason); Taken at admission means Busy(reason). Found still requires authoritative residency/visibility validation. Recommend temporary inaccessible live state as admission Busy(Unavailable) (reason spelling provisional), never Stale or fabricated defaults. Reads must not load, pin, swap parked state or retry. During apply, a newly Taken or invalidated prerequisite is Stale(reason), following the accepted action note. Transfer preserves entity identity and resolves its current level. World close invalidates old handles permanently; reload needs fresh handles, as the Part note requires.
Use InvalidInput(reason) for malformed operation-owned input, retaining structured text-form reasons for action_bar. Preserve meaningful existing cancellation/refusal semantics rather than classifying every false as stale. Canceled and ListenerFailed remain action outcomes only where dispatch actually occurs. Reads take &dyn Host; writes take &mut dyn ServerHost; catalog side restrictions still govern both targets. Wrong-side writes must fail compilation.
Writes participate in the existing transaction and revalidate before mutation. In lifted passes, retirement and journal emission wait for root commit; abort publishes no packets or effects. Refuse saves during an open action savepoint. No mutable borrow crosses dispatch. Caught native unwinding and wasm traps follow ListenerFailed and rollback policy; process-aborting panics cannot be recovered.
Evidence and route owners
Current owners below were inspected; they establish entry points, not complete migration or a verified transitive route audit. Single-player server, LAN and dedicated server must share authoritative resolution; remote client reads must obey replica visibility without gaining write authority.
| Route | Verified file:line owner |
|---|---|
| Entity position / level | crates/horizon-mod-vocab/src/host/entities.rs:123,143; crates/horizon-client/src/game/modding/native_entity_refs.rs:675,735 |
| Entity clear_fire | crates/horizon-mod-vocab/src/host/entities.rs:744; crates/horizon-client/src/game/modding/native_entity_state.rs:232 |
| Player name / action_bar | crates/horizon-mod-vocab/src/host/players.rs:54,302; crates/horizon-client/src/game/modding/native_server.rs:1152,1196 |
| Level block reads | crates/horizon-mod-vocab/src/host/levels.rs:190,211,217; crates/horizon-client/src/game/modding/native_level_refs.rs:433,518,530 |
| Lifecycle lookup | crates/horizon-client/src/join_seq/residency/table.rs:24 defines Found, Taken, Missing |
Vanilla 26.3 inspection via /workspace/horizon/tools/rea_vanilla.py method net.minecraft.world.entity.Entity isRemoved shows removal is tested by a non-null removal reason. It does not define Rust retained-handle errors or verify all read/write routes. This proposal changes Horizon lifetime reporting, not vanilla gameplay. The issue asserts no NeoForge counterpart. The public 26.3 Entity Javadoc was inaccessible; NeoForge 26.3 lifetime behavior is unverified. Require public documentation before adopting any claimed counterpart; no NeoForge source or patches were consulted.
Generate API-owned Wire values and typed refusals for native and wasm from one definition. Unsupported capabilities must refuse registration/load with the type and capability named, before first access. Treat input handles, discriminants, positions and text as untrusted; validate with existing authoritative scope and Wire/text bounds, checked arithmetic and operation-specific ranges. Preserve vanilla capacity; introduce no arbitrary new numeric cap. No mod-side revalidation protocol. Unused paths add only O(1) holder validation, with zero listener scans, callback dispatch, encoding or per-holder allocations; measure this before runtime closure.
Acceptance matrix
Proposed implementation tests on native and wasm, not results of this documentation change:
| Fixture | Expected result |
|---|---|
| Live entity at [1,2,3] | Position success [1,2,3] |
| Retain handle across despawn; clear_fire | Stale; zero mutations |
| Close/reopen same world, reuse numeric id | Old handle Stale; fresh handle succeeds |
| Taken at admission / during apply | Busy / Stale; zero mutation and no retry |
| Live identity temporarily inaccessible | Busy(Unavailable); no load or fabricated name/block |
| Entity transfers X to Y | Same epoch/id resolves Y |
| Valid loaded air block | Successful air value, not refusal |
| Malformed text | InvalidInput with text-form reason; zero packets |
| Listener writes then stale apply or caught panic/trap | Rollback; zero committed effects; next built-in action succeeds |
| Wrong-side write | Compile failure on both targets |
| No listeners | Zero dispatch/encoding/all-mod scans |
Proposed implementation stages
File: none. First confirm the unavailable mapping with the world-visibility/residency owner and inventory every ordinary operation and semantic absence/refusal. Then generate the shared result representation and migrate those methods, callers and sample together. Finally exercise real retained handles, transaction failure recovery, native/wasm parity and unused-path cost. Adjacent residency and visibility work supplies validation; it is not evidence that these result surfaces already exist.
Open questions
- Temporary unavailability: recommend Busy with a structured Unavailable reason, preserving admission versus apply semantics. The world-visibility owner must confirm suspended-player and unloaded-level classifications before runtime code; do not infer stale merely from failure to access storage.
- Per-operation refusal mappings: recommend keeping domain absence separate from lifetime failure and retaining structured text errors. Audit each covered method before freezing public signatures and Wire discriminants.
Design: transactional block-entity removal
This is a contract for a future implementation, not an API available today. It covers removal of a block entity through its owning block, not deletion from the block-entity storage map. It does not specify a new method signature. The existing block and transaction paths need changes before this contract can be offered.
What the existing paths do
Game::level_set_block in game/modding/native_entity_refs.rs checks the level and position, then
calls Game::native_set_block in game/native_hooks/. The latter calls
GameLevel::native_set_block through with_level, then processes level events. The level method
calls behaviours.set_block and creates a new block entity when the landed block needs one. This
is the normal block lifecycle path; it is not currently made transactional by
level_set_block.
The separate Game::journal_set_block in game/runtime/transactions.rs is currently used by the native
fluid handler, not by that public block-write path. It keeps the first block state at each depth,
writes only the stored state, and queues After::Block. transaction_close merges a committed
child's first snapshots into its parent, retaining the parent's earlier snapshot, and appends the
child's queued work. root_commit restores the original stored state temporarily and calls
level_set for the final state; roll_back restores stored states without those updates.
level_set calls behaviours.set_block and processes level events. This fluid-oriented route is
not by itself a complete replacement for GameLevel::native_set_block: in particular the latter
also creates the appropriate new block entity.
journal_block_entity snapshots the entity, attachments, hopper state, custom name and extras
(including lock and loot state) on the first write at each depth. However, roll_back currently
restores the entity only if both the snapshot and a current entity exist. It cannot restore a
removed entity or remove a newly created one. After::Changed and After::Updated are also
position-based; root_commit coalesces Changed but does not generally coalesce Updated.
Neither direct deletion nor assuming the present journal already handles lifecycle changes is safe.
The vocabulary's BlockEntityRef, re-exported by mod_api, identifies a level, position and lifecycle generation.
native_block_entity_target rejects a replacement or reloaded entity at that position, just as it
rejects an absent entity or an old world epoch. In-place payload edits preserve identity. The
contract below preserves this identity rule; it does not offer transactional lifecycle writes yet.
Removal contract
- Removal requires a valid, loaded block entity in the authoritative level in place. Stale-world,
unloaded, missing and wrong-level handles are refused without changes. Validation must be at least
as strict as
native_block_entity_write_keyandnative_level_write_allowed. - The operation changes the owning block through the normal block-write lifecycle. It must not leave a block that requires a block entity without one: removal while retaining such a block is refused. A same-position replacement that needs an entity creates the replacement through the block-write path. Whether an operation chooses air, a fluid legacy state, or another valid replacement is an explicit part of the future API contract, not an implicit storage deletion.
- The old container's contents, saved data, lock, name, attachments and capability-backed state belong to the old entity. On commit the normal block lifecycle determines drops and other removal effects; they must happen exactly once. They are not silently copied onto an unrelated replacement. A replacement's capabilities resolve from the replacement, not the old entity. In-place block state transitions that preserve an entity follow the existing block lifecycle instead of being treated as removal.
- At each nesting depth, reads and capability lookups must see the tentative result: after removal there is no old entity to access, and after replacement lookups address only the replacement. The implementation must retain enough old state to restore existence as well as data and attachments. It must not expose a partially removed container or leak old capability access.
- No drops, lifecycle notifications, client updates, comparator changes, menu closures or other
externally visible removal effects may escape an abort. At root commit, publish the successful
block transition and its normal effects in their lifecycle order, once. Existing
Afterentries targeting an entity that was removed or replaced must not update the wrong entity; their ordering and invalidation must be implemented deliberately, not inferred from position alone. - An inner abort restores the state at entry to that depth and discards its pending effects. An inner commit transfers its work to its parent; a later outer abort restores the state from before the outer transaction. Root commit makes the final state durable and publishes its effects. Unwinding must abort just as an ordinary failure does. If multiple writes at one position occur, the result and side effects must respect their documented order without duplicating drops or updates.
- While absent, an existing handle resolves to no entity. After abort it resolves to the restored entity and generation; after a committed replacement it stays stale. A new handle must be acquired for the replacement. A handle from an earlier world open remains invalid. A retained capability reference must not bypass these resolution and lifetime rules.
Example: a chest
A loaded chest has items, a lock and an attachment, and a player is viewing it. A transaction requests a valid block replacement that removes the chest. Within the transaction, block and block-entity reads see the tentative replacement; the old chest's capabilities cannot be used. If a nested transaction commits and the root then aborts, the original chest, items, lock, attachment, block state and usable capability access are restored; the player has not seen a removal, received items or had the menu closed by that aborted removal. The same restoration applies to an inner abort, relative to the inner entry state.
If the root instead commits, the normal block replacement runs once, with its normal handling of contents and viewers, and clients receive the resulting block and entity changes. The old chest is no longer accessible through its old handle; if the new block has a block entity, acquire a new handle for it. Actual drop and menu behavior must match the block's normal lifecycle rather than an invented generic chest rule.
Required implementation tests
- Remove a populated, locked container with an attachment, commit, and compare block, contents, drop count, capability resolution, viewer closure and client notifications with an equivalent normal block replacement. Prove effects occur once.
- Abort the same removal and assert the full original state, capability access and viewer state, with no drops, updates or notifications escaping. Repeat for an inner abort, a committed child followed by outer abort, and unwinding.
- Exercise remove then replace, replace then remove, and multiple writes at one position, including queued entity updates; assert no stale update reaches a replacement and no effect is duplicated.
- Check absent, unloaded, wrong-level and old-epoch handles, a replacement that creates a new entity, and refusal to retain a block requiring its entity. Verify position handles resolve as described both before and after abort and commit.
- Cover block-entity absence and creation in the journal, attachment and capability state, and same-block lifecycle-preserving transitions. Exercise client and server observable outcomes, not only journal calls.
Decisions for follow-up implementation
- Define the removal operation's replacement state and flags, including fluid preservation and whether the caller may choose a replacement; reject retaining a block that requires an entity.
- Extend the existing coordinator to journal existence and tentative visibility, while retaining
the full
GameLevel::native_set_blocklifecycle at publication. Decide how ordered intermediate transitions and failure at publication are handled without leaking irreversible effects. - Define how queued per-position entity updates are tied to the entity they describe, and how retained capability references revalidate after removal, replacement and abort.
- Make lifecycle effects, drops, menu closure and client notifications atomic with root commit; specify their order against other writes and prove it with the tests above.
- Coordinate container viewers and limits in #975 and lock and replacement writes in #977 with these existence, rollback and replacement rules; neither may introduce a second storage route.
Design: travel and move hooks
Proposal for #1009, not an implemented API. This is a clean-room Horizon design from vanilla 26.3 and the requested use cases. There is no NeoForge counterpart. No other project's source informs this proposal. Code follows agreement on this note.
Moments and typed contract
Declare exactly two catalog moments: living_travel and entity_move. Both use
HostSide with typed ServerHost or ClientHost access, never an unchecked side
flag granting server writes. Entity reads and authoritative writes belong on typed
entity/player handles; do not add entity-id movement methods to Host.
Travel fires once at the LivingEntity.travel boundary, after ordinary input and
attribute resolution and player swimming preparation, before fluid/air/glide
selection. Its owned snapshot contains a living-entity handle, optional player
handle, level/entity epoch, input vector, velocity, orientation, grounded/fluid/
flying/gliding state, resolved attributes, simulation tick and action identity.
The output is Continue, Cancel, or Replace { input, velocity }. Replacement
supplies the branch's starting values; it does not replace the physics algorithm.
Cancel skips this travel invocation, including its nested moves and flying velocity
restoration, leaving its entry position and velocity intact. Earlier input/jump
processing is not undone. Speed, jump, gravity and step remain attributes; neither
hook adds multipliers or a parallel attribute system.
Move fires once per physical Entity.move invocation, including external pushes,
before no-physics handling, piston limiting, stuck multipliers, edge backoff and
collision. Snapshot includes entity handle, starting position/AABB, requested delta,
velocity, typed cause (Self, Piston, ShulkerBox, Shulker or Vehicle), epoch and
identity (connection epoch, tick, action sequence, invocation ordinal). Output is
Continue, Cancel, or ReplaceDelta(Vec3). Cancel skips that invocation without
changing position, velocity, collision flags or fall state and emits no displacement
notice. Replacement still passes through all ordinary restrictions. Collision,
step height, grounding, fall damage, restitution and movement emissions use the
actual resulting displacement in vanilla order. Travel and move are distinct
moments: a travel replacement can still encounter a move veto. No second hook is
added for individual branches or replay.
Vectors use owned finite f64 components. Travel input components must lie in [-1,1]; replacement velocity and move delta components in [-64,64] blocks per tick or call. These are proposed mod-output budgets, not limits applied to vanilla Continue. Reject the entire malformed result (NaN, infinity, out-of-range, including negative magnitude budgets); signed negative vector components within range are valid. Reject rather than clamp, retain the preceding valid proposal, and diagnose once per listener/tick. Dependency-ordered listeners see the preceding valid proposal; Cancel is terminal. Panic or wasm trap rolls back that listener's writes, discards its result and disables it for the session; continue from the preceding proposal. Native abort cannot be recovered. Native and wasm otherwise share validation.
Ownership and route map
References verified at worktree HEAD 9556bd72a8; paths below are relative to
crates/horizon-client/src/. These are current owners, not claims that hooks exist.
| Route / owner | Current file:line | Required integration |
|---|---|---|
| Player travel / air / glide | motion.rs:1487, :1167, :2187 | One travel decision before branch selection; water, lava, air, glide and creative flying covered |
| Physical player movement | motion.rs:2297, trait adapter :3039 | Shared move operation, retain cause through adapters |
| Shulker box push / piston limit | motion.rs:2292, :2492 | Same move moment before external-motion restrictions |
| Controlled window simulation | game/network/remote/mod.rs:7129 | Predict with client-side context |
| Local tick / replay | motion.rs:1367, lan/prediction.rs:900 | Same deterministic decision/apply path; replay is not a new action |
| Integrated, LAN and dedicated guest turns | game/network/lan_actions/turns.rs:6, :18, :45 | Server context bound to acting guest, never host player identity |
| Action service and scoped fake actors | game/modding/native_server.rs:538, :549 | Enter existing player action validation; physical motion subsequently uses shared hooks |
| Normal living mobs | mobs/living.rs:1451, :1544 | Lift decision to game adapter; include mob air and fluid/glide equivalents, not just player motion |
| Shared air equations | ../horizon-world/src/living_air.rs:134 | Keep physics below game independent of Host |
The last path is relative to the crates directory instead of the client source
root: crates/horizon-world/src/living_air.rs:134 is the shared owner.
Vehicle movement must use the entity move boundary for the vehicle itself;
passenger positioning is not a second travel or physical move. Shulker pushes and
other external physical displacement preserve their cause and invoke move even
without travel. Teleports, packet corrections, remote interpolation and passenger
seat assignment are position assignments, excluded from both hooks. Remote entities
are not predicted locally; their authoritative server invokes the hooks. An action
service request is not itself travel: do not manufacture movement on attack/use.
Implementation must inventory vehicle and nonliving collision adapters before code
lands and route their actual Entity.move equivalents through this boundary.
Transaction, prediction and replay
Use decide/fire/apply: lower physics prepares a bounded immutable decision, the game dispatches with Host, and apply revalidates entity existence, level epoch, position, velocity, attributes and collision/environment revision in the same transaction as listener writes. A changed precondition refuses the decision and rolls back that transaction; never recompute against changed state mid-callback. No listeners takes the existing path without snapshots, allocations or mod scans.
Integrate with the client action pipeline, preserving its input/action ordering and server validation. Negotiate movement-rule identity and deterministic configuration before enabling prediction. Server validates intent, attachment state, reach and environment independently; it never trusts a client's replacement delta. Server simulation is the sole authoritative result. Unmatched client rules disable custom prediction and await authoritative movement.
Record input, tick/action identity, mod rule version and deterministic attachment state with each prediction checkpoint. Corrections restore those values and rerun the same rules against corrected environment snapshots. Do not reuse outputs from a divergent environment. Predicted callbacks may update only rollback-owned state; server writes remain server-only. Sound, particles, packets, damage and other external effects commit through an identity-deduplicated effect journal, never inside replay callbacks. Server damage uses the authoritative collision result.
A grapple stores an anchor on the entity and contributes bounded velocity before travel; server checks anchor reach and obstruction. A glider replaces travel input and starting velocity while vanilla chooses the glide branch and applies gravity. Wall-running uses a recorded wall normal to remove velocity into the wall, then ordinary collision prevents penetration. Loss of anchor/wall invalidates the rule; client and server return Continue from the same rule rather than inventing a teleport. Each example uses attributes for speed/jump/gravity/step adjustments.
Acceptance matrix
These are implementation requirements, not tests claimed to have run. Geometric cases isolate move from travel acceleration and gravity.
| Fixture | Concrete expected result |
|---|---|
| Empty world, start (0,64,0), delta (1,0,0), Continue | Position (1,64,0), one committed displacement notice |
| Same, Cancel | Position (0,64,0), zero displacement notices |
| Same, ReplaceDelta (0,0,1) | Position (0,64,1), one notice |
| AABB x extent [-0.3,0.3], solid wall plane x=1, delta (2,0,0), no step opening | Center x=0.7, right face x=1, zero penetration |
| Replacement NaN, infinity, 65 or -65 on one axis | Entire result rejected; original delta (1,0,0) still yields (1,64,0) |
| Replacement (-1,0,0) | Position (-1,64,0) |
| Listener panics after attachment write | Write rolled back; original delta applied; listener disabled |
| Callback changes level epoch or starting position | Stale decision refused; zero movement from that decision |
| Two identical corrected replays of fixed input sequence | Final position/velocity and attachment bytes equal fresh authoritative simulation; exactly one external effect per action identity |
| Host window, LAN guest, dedicated player; native and wasm | Each geometric case above has identical values and correct acting-player identity |
| No listeners: air, water, lava, glide, flying, piston, vehicle | Exact position/velocity/collision/fall-state equality with vanilla 26.3 oracle fixtures for the same inputs |
| Teleport/correction/interpolation/seat assignment | Zero travel/move hook calls for the assignment itself |
Vanilla was inspected with python3 tools/rea_vanilla.py method net.minecraft.world.entity.LivingEntity travel and the corresponding Entity move
query using the prepared jars under /workspace/horizon. The former selects fluid,
glide then air; the latter limits piston movement before collision and applies fall
and emission behavior afterward. No vanilla implementation is copied here.
Open questions
- Exact vehicle/nonliving adapter census is implementation work still needed. Recommend requiring a file:line route inventory and oracle fixtures before the hook implementation is approved; never ship player-only coverage under Entity.move.
- Prediction checkpoint transport for mod attachment state is not specified by the existing action note. Recommend a negotiated, versioned bounded schema (64 KiB per entity checkpoint, reject overflow without prediction), with server snapshots and rule identity; agree its wire ownership before implementing prediction support.
Design: typed payloads
Accepted design for #813, with vanilla-compatible framing. Stage 1 is in the code (see Stage 1); nothing registers, negotiates or delivers a typed payload yet. Registrar and send services remain object-safe.
Decision: shared codec, registration and delivery
Register a typed value codec, phase, direction, schema version, required/optional peer policy and exactly one owning handler per receiving side at load. Freeze declarations after dependency-ordered registration. Resolve namespaced identifiers once into connection-local numeric slots; dispatch by slot directly to the owner, never broadcast to all mods. Duplicate declarations fail load, including identical duplicates. Only the declaring namespace may register its payload; dependencies use its exported typed token.
Propose ToServer
Share the typed schema/value foundation proposed by PR #2332 (#811): the mod owns schema and pure codec, the game owns orchestration, validation and publication. Separate data-format decoding from network encoding while reusing primitive bounds, structured errors, registry references and generated native/wasm adapters. Wire is an ABI representation, not permission to serialize Rust memory, pointers or Any. Erase typed callbacks internally once at registration with a checked type token. Do not invent a second codec framework for networking. Horizon signatures remain proposals; the verified counterpart contract is recorded below. Before implementation, specify API-owned registrar and send-service signatures: keep generic typed registration outside dynamic dispatch, and typed send helpers outside the object-safe Host service, delegating to a non-generic erased send operation. Tokens bind the registered declaration, phase and load-session identity; validate these plus direction and connection epoch before sending. Forged or stale wasm tokens return a structured refusal without dispatch. Erased handlers recover only their registration-checked type. Generate Wire adapters for every supported type, or refuse load explicitly with the unsupported type named on both native and wasm; no implicit adapter failure.
Stage 1: capacities, tokens and send services
crates/horizon-mod-vocab/src/content/payloads.rs holds the API-owned types the later stages build on. Token values, including Wire-decoded ones, are untrusted claims. Only admission against the engine-owned frozen declaration table grants a slot; there is no send implementation yet.
| Item | Contract |
|---|---|
SERVERBOUND_PAYLOAD_CAPACITY, CLIENTBOUND_PAYLOAD_CAPACITY | Discard-fallback body limits only, not registered-payload caps |
PayloadPhase (Configuration, Play, Common), ConnectionPhase | permits says whether a declaration may travel in the connection's phase |
PeerPolicy (Required, Optional) | As under Negotiation and absent mods |
PayloadToken | The erased token: slot, phases, direction and the load session that issued it. Private fields, issued by the game. admit(direction, load_session, phase, declarations) returns the slot or StaleToken, WrongDirection, WrongPhase, after exact comparison against the engine-owned frozen slot table, before any codec or handler runs |
ToServer<T>, ToClient<T> | A PayloadToken bound to its value type and direction. Binding a token of the other direction fails |
ServerboundPayloads::send_erased(token, body), ClientboundPayloads::send_erased(player, token, body) | The two object-safe send services, one erased operation each. A constant in the module stops compiling if either stops being usable behind dyn |
PayloadSendError | PayloadTooLarge, UnsupportedPeer, Backpressure, StaleToken, WrongDirection, WrongPhase, NotConnected; has a Wire form |
The typed send helper (stage 2, with the codec) is generic and lives on the token: ToServer<T> encodes a value and calls ServerboundPayloads::send_erased, ToClient<T> calls ClientboundPayloads::send_erased. A ToClient<T> has no method taking the serverbound service, which is the compile-time refusal the acceptance matrix asks for. The connection epoch is the connection's, not the token's: the send service checks it with the token.
Evidence and limits
The clean-room 26.3 contract confirms registrar modifiers versioned, optional
and executesOn return copies; registration methods return the registrar.
Play, configuration and common each support ToClient, ToServer and Bidirectional.
Handlers default to MAIN. Required missing payloads refuse admission; optional
ones may be absent. NeoForge peers compare versions exactly (trimmed strings).
Duplicate ids in the same protocol and registration after setup are refused.
These are contract evidence, not claims that Horizon has implemented negotiation.
The 32,767 serverbound and 1,048,576 clientbound constants limit only the unknown-id discard fallback body in both phases. Registered payloads have no per-payload cap beyond their codec and frame/compression limits (2,097,151 framed bytes and 8,388,608 uncompressed bytes). They are not typed-envelope ceilings.
Negotiation and absent mods
Exchange bounded manifests during configuration, before accepting application payloads. Match identifier, schema version, direction and phase; assign deterministic slots to their intersection. Required missing or incompatible declarations refuse admission with a diagnostic naming both versions. Optional absence yields UnsupportedPeer on send and no packet; never silently substitute raw delivery. Vanilla peers may join only when every declaration needed for admission is optional. Mod package dependency versions and network schema versions are separate checks. Reconfiguration invalidates slots and queued messages from the old connection epoch; renegotiate before play resumes. No version gates retaining an obsolete API or schema conversion shim.
Provisional limits: 4096 manifest entries, identifier 129 UTF-8 bytes, version 64 bytes, manifest total 1 MiB. Reject oversized manifests before creating entries. A forged unknown slot in the negotiated transport is a protocol error; unknown unrelated vanilla/plugin channels retain their existing behavior. Reserve the typed envelope channel against generic send_packet injection without banning unrelated plugin channels.
Bounds, trust and delivery
Registered payloads obey their codec and frame limits, not discard-fallback limits. Depth 64, strings 4096 UTF-8 bytes, lists 4096 entries, decoded allocation budget at least sufficient for a full legal envelope; the earlier 256 KiB proposal cannot reject otherwise legal payloads. Structured codec bounds are reviewed per schema so they do not become a lower transport ceiling. Validate signed lengths, integer overflow, remaining bytes, collection counts and cumulative budget before reserve/copy/allocation. A small encoded count must not permit huge decoded objects. Require complete consumption, valid discriminants, finite domain floats where declared and valid registry references. Bound outer framing/decompression before custom decoding; a later codec check cannot repair an earlier unbounded allocation.
Both native input and wasm input are untrusted. Generated bounded primitives enforce these limits; custom codecs require runtime allocation/fuel accounting and must not escape their input scope. Native arbitrary allocation cannot be sandboxed: native code must obey the codec contract, and process-aborting panics remain unrecoverable. Catch unwind/traps at the codec/handler boundary, abort the active transaction, discard pending sends, disable/report the offending callback under runtime policy and close the affected session. Never unwind through the ABI. Invalid remote data disconnects its sender without invoking the handler; local encode failure returns a structured error and sends nothing. Revalidate authority, holder incarnation and handles at apply, not only decode.
Run mod codecs and handlers on the logical receiving main thread, with bounded byte queues from network I/O. Preserve per-connection receive order across payload types and ordinary gameplay actions at the same ingress queue; do not promise ordering across connections. Handler sends become visible after successful root transaction commit; nested commit merges and abort emits zero packets. Queue acceptance is not delivery acknowledgement. Replies run at the next receiving dispatch turn, including integrated play, preventing recursive callback delivery. Configuration completion waits for required sync acknowledgement. Propose 32 messages per peer per tick, with byte and connection queue budgets large enough to admit a complete maximum-size legal envelope plus queue bookkeeping; a byte scheduling budget must allow one such message to make progress; reject local sends with Backpressure, disconnect flooding remote senders. No silent truncation or unbounded retry.
Stage 2: the value codec
crates/horizon-mod-vocab/src/content/payload_codec.rs holds the pure codec half of stage 2, exported from mod_api. It is the part that touches neither registration nor delivery, so it stands apart from the raw transport of #2882. Nothing calls it yet: the typed handler adapter that decodes a received body, and the typed send helper that encodes one, arrive with registration.
| Item | Contract |
|---|---|
PayloadCodec | The trait a payload's value type implements: encode(&self, &mut PayloadWriter) and decode(&mut PayloadReader) -> Result<Self, PayloadDecodeError>. Both see only the buffer, never a Host. Implemented for bool, u8, i8, i16, u16, i32, i64, f32, f64, VarInt, VarLong, String, Vec<T>, Option<T> and Box<T> |
| Encoding | Vanilla's network encoding, so a ported payload keeps its exact bytes: big-endian numbers, VarInt and VarLong, a VarInt byte length before a string or byte array, a VarInt count before a list, one boolean byte before an optional value |
decode_payload::<T>(body) | The whole body as one T; a byte left over is Trailing { bytes } |
encode_payload(&value, limit) | The body, or PayloadEncodeError; the byte limit is the caller's (the transport's frame bound), checked before the buffer grows |
PAYLOAD_MAX_DEPTH | 64. Each list, each present option and each nested level counts one; level 65 is TooDeep |
PAYLOAD_MAX_STRING_BYTES, PAYLOAD_MAX_LIST_ENTRIES | 4096 each, the defaults of String and Vec<T>. A schema with another bound calls str_within(limit), bytes(limit) or list(limit, read) itself, so these are not a transport ceiling |
PAYLOAD_DECODE_BUDGET | 8,388,608 bytes of allocation for one value. A list is charged count * size_of::<T>() before it is reserved, an owned string its length, so a small body cannot ask for a large allocation |
PayloadDecodeError | UnexpectedEnd, VarIntTooLong, NegativeLength, LengthOverLimit, Utf8, TooDeep, InvalidDiscriminant, NonFinite, BudgetExhausted, Trailing, InvalidValue; has a Wire form |
PayloadEncodeError | TooLarge, LengthOverLimit, TooDeep, NonFinite; has a Wire form. An encoder refuses what the peer's decoder would refuse |
A length is read as a signed VarInt and checked for sign and bound before anything is reserved or copied; strings and byte arrays are borrowed from the body. A boolean other than 0 or 1 is refused (vanilla reads any non-zero byte as true). finite_f32 and finite_f64 are how a schema declares a finite float. No decode path indexes, and none panics on any input.
Two things this stage does not do. The bounded field kinds of #811 (DatapackFieldKind) are not merged with this reader: they describe JSON entries the game decodes, are named for datapacks, and their issue is in flight; a shared name for the two belongs to whoever lands second. And the budget counts what the reader's own primitives allocate, not what a hand-written codec allocates beside them; native code has to obey the contract, as the section above says.
Routes to replace
Owners verified again for stage 1 (main at 8b8578047a), named by function because line numbers drift. Paths are under crates/horizon-client/src/ unless they start with crates/. These are current code owners, not claims that typed delivery exists.
| Route | Owner and treatment |
|---|---|
| Server send to player | game/modding/native_net.rs net_send_to_player queues, flush_native_net builds the packet; replace byte queue with typed envelopes and negotiated recipients |
| Client send to server | Same file, net_send_to_server; replace the remote.mod_net boolean with negotiated token/phase checks |
| Server receive, LAN and dedicated | game/network/lan_actions/packets.rs lan_guest_packet to native_net.rs guest_native_net, queued dispatch in run_native_net; both must enter one ordered typed ingress |
| Client receive | game/network/remote/mod.rs handle_packet to game/modding/native_book_net.rs remote_book_or_mod_payload to native_net.rs remote_native_net; replace immediate raw callback with ordered delivery |
| Raw hook adapters | game/native_hooks/base.rs native_net_from_client and native_net_from_server; delete along with crates/horizon-mod-vocab/src/hooks/net_from_client.rs and net_from_server.rs |
| Public sends | crates/horizon-mod-vocab/src/host/network.rs net_send_to_player and net_send_to_server; delete raw methods and regenerate native/wasm API, docs and sample callers together |
| Configuration advertisement | lan/conn.rs login_and_configure sends the empty horizon:mod_net payload; net.rs receive_brand turns it into Event::ModNet; game/network/remote/mod.rs poll_remote_events sets and resets remote.mod_net. Replace the announcement with the manifest exchange |
| Server dispatch and flush | game/network/lan/mod.rs tick_lan runs run_native_net after the tick's packets and flush_native_net at its end; native_players_leaving (game/native_hooks/shared.rs), stop_integrated (game/world/integrated.rs) and stop_dedicated (game/interface/dedicated/mod.rs) flush once more. Preserve these commit boundaries |
| Integrated and split-screen client | game/network/remote/mod.rs connect_local, called for the window and by game/runtime/split/players.rs. Use the same negotiated transport and queued handlers for each logical client, never direct calls |
| Attachment sends and receive | game/modding/attachment_sync.rs recipients and attachment_snapshot select recipients and build snapshots, flush_attachment_sync sends through lan/guest.rs send_attachment_sync; the client's handle_packet calls remote_attachment_sync. Migrate to reserved typed declarations, retain tracker authorization |
| Codec classification | crates/horizon-protocol/src/packets/custom_payload.rs uses_registered_payload_codec is the seam the frozen declaration table replaces; horizon:mod_net and horizon:attachment_sync leave it with their modules |
Generic packet sending (send_packet in game/modding/native_net.rs) must also reject forged typed transport packets. This does not remove vanilla packet interop. The execution trace below gives the socket and scheduler owners.
Execution trace
Read from the code for stage 1; nothing here was measured or run.
Server, every kind (LAN host, dedicated, single player's server thread). One code path: Game with a LanHost.
- Accept.
lan/mod.rsaccept_loop(threadhorizon-lan-accept) starts ahorizon-lan-connthread per socket;Lan::connect_localstarts ahorizon-local-connthread on one end of an in-memoryPipefor the integrated server's own player and each split-screen player. Both runlan/conn.rsserve_connection. - Login and configuration run on that connection thread, blocking, in
login_and_configure: it readscfg::Serverboundpackets in three loops (toSelectKnownPacks, to thePong, toFinishConfiguration) and sends directly. No game state is reachable there; it sees onlyShared. The mod list is compared here. A typed manifest exchange and any configuration-phase handler therefore need a hand-off to the server's main thread that does not exist today: stage 3 has to add it, and configuration completion has to wait on it. - Play ingress.
playmoves the socket to the I/O pool (lan/io_pool.rs, one thread per two cores, a connection pinned to one thread).PlayConn::packetdecodes each frame withplay::Serverbound::decode(which applies the directional custom-payload bound), counts it againstUndrained(twice the inbox caps, else a kick) and sendsNetEvent::Packeton one channel shared by all connections. - Main thread.
Hub::poll(first level pass of a tick) movesNetEvent::Packetinto the guest'sinboxin arrival order (capsINBOX_LIMIT,INBOX_BYTES).Hub::drain_packetsruns each guest'sprocess_inboxand emitsHostEvent::Packetin that order;lan_process_eventscallslan_guest_packetfor each, with the guest's state swapped in. Mod messages are not delivered there:guest_native_netonly queues them, andrun_native_netfires the hook after all of that pass's packets, with no player swapped in. So one connection's mod messages keep their own order but run after its gameplay packets of the same tick. The design's "same ingress queue" order needs the typed handler to run at the packet's place inlan_process_events, or the note to accept the current rule; stage 3 decides and tests it. - Egress. A hook's send is held in
NativeNet::to_playersuntilflush_native_netat the end oftick_lan, which hands bodies to each guest'sPlayerSender(boundedOutbox);lan::flush_allthen posts them to the I/O pool, which frames, compresses and writes. Frames are bounded byframe::MAX_FRAME(2,097,151 bytes) andMAX_UNCOMPRESSED(8,388,608) on sockets, not on the in-memory pipe. - Reconfiguration. This server never sends
StartConfiguration. Only the client handles it, for other servers and proxies.
Client.
net.rsSession::connectandconnect_localstart one network thread that runsrun. Itsconfigureloop handlescfg::Clientboundon that thread: it answers the brand, sends the mod list, and reportsEvent::ModNetand the like to the game.FinishConfigurationis answered there too, so the game thread does not gate it today.- In play the thread forwards
Event::Packet.StartConfigurationbecomesEvent::Reconfiguringand the thread configures again. poll_remote_eventson the game thread takes the events in order and callshandle_packet, which calls the mod hook at once (remote_native_net), in order with every other packet. The window, a split-screen player and a LAN guest all take this path; the integrated server's replies arrive through the pipe, never by a direct call.
What the trace leaves to stage 3: the configuration hand-off on both sides (server step 2, client step 1), the ordering choice in server step 4, and a reconfiguration path on the server if a payload ever needs one.
Shared synchronization and clean break
Carry server config snapshots during configuration with schema and revision, validate atomically, acknowledge before admission, and discard on disconnect; never overwrite the client's local config file. Live changes publish a complete committed revision or trigger reconfiguration when required. No separate config transport.
Follow PR #2328 (#796): attachment envelope includes world/dimension, holder incarnation, residency join, schema and committed revision. Full snapshot on tracking entry precedes deltas; send only to authorized trackers/owner. Stale joins/incarnations/revisions cannot mutate replicas. Gaps request a fresh bounded snapshot; visibility loss clears the replica. The proposed 64 KiB attachment value is subject to codec and frame limits including envelope overhead. Use bounded snapshots with atomic publication; chunk only when the complete envelope would exceed transport capacity. Assembly budgets must accommodate the declared snapshot and legal transport capacity, with timeout after 100 ticks; larger snapshots fail explicitly rather than imposing the earlier 256 KiB transport ceiling. Root rollback sends nothing. Client replicas never authorize server state changes.
Delete raw mod_net messages, net_send_to_* methods, net_from_* hooks, old transport advertising and separate attachment transport in the implementation change. Update catalog, generator, native and wasm consumers, tests, book, all old hook documentation, wasm coverage rows and horizon-machine-sample together. These are implementation prerequisites. No compatibility alias, fallback handler, migration shim or dual transport. Native and wasm have identical registration, dependencies, limits and semantics; wasm uses validated opaque tokens and copied bounded values, never guest pointers.
With no declarations, perform one empty-table check at connection setup, no mod manifest traffic, no codec calls, payload allocation or per-tick all-mod scans. Registered delivery costs one slot lookup plus work proportional to the payload and actual recipients. Attachment dirtiness must not trigger encoding when no negotiated recipient needs the value. No-listener means no callback fanout, not skipping validation of an already received transport envelope.
Acceptance matrix
Required future tests, not runtime results of this note.
| Fixture | Concrete expected result |
|---|---|
| ToServer counter=7; reply ToClient counter=8 | Exactly one server handler sees 7 and one client handler sees 8, on respective main threads, native and wasm |
| Send ToClient through serverbound service | Compile-time refusal; forged direction invokes 0 handlers and disconnects sender |
| Two owners declare cells:update | Load fails with both owners; 0 installed duplicate slots |
| Required schema 2 versus peer schema 1 | Admission refused; optional mismatch returns UnsupportedPeer, 0 packets |
| Length -1, count 4097, depth 65, trailing byte | Reject before collection allocation; 0 callbacks; next valid independent session accepts counter=7 |
| Forged or stale wasm token; unsupported Wire type | Structured token refusal, 0 handlers/sends; unsupported type named at load on both targets |
| 33 sends in one tick | First 32 accepted within byte budgets; last Backpressure |
| Receive A=1, B=2, A=3 on one connection | Handler order 1,2,3 on integrated, LAN and dedicated |
| Handler sends reply then panics/traps | 0 replies published, transaction restored, callback disabled, session closed |
| Config revision 4 sets counter=7 | Client admits only after acknowledgement; local file unchanged; disconnect clears override |
| Attachment join 10 revision 11 value 8; late join 9 value 3 | Replica remains 8; non-trackers receive 0 updates |
| Root abort after attachment edit 7 to 9 | Authoritative value 7, 0 sync packets |
| 100 ticks with no declarations | 0 codec calls, typed packets and payload allocations |
| Generic send_packet for typed channel / unrelated plugin channel | First refused; second retains existing interop behavior |
Proposed implementation stages
(1) Apply the accepted vanilla limits and object-safe service requirement; verify public 26.3 contract and complete socket/scheduler route trace. Contract evidence recorded above. (2) Share #811 codec primitives and generated native/wasm registration with bounded adversarial decoding fixtures. (3) Add configuration negotiation and ordered dispatch through integrated, LAN and dedicated routes. (4) Move config and #796 attachment sync to this transport with atomic snapshot tests. (5) Delete every raw caller/catalog surface and update samples in the same clean-break change; demonstrate the matrix and allocation measurements.
Open questions
- Shared codec ABI and custom allocation enforcement? Recommendation: approve one generated bounded foundation with #811; require a reviewed accounting interface before accepting custom network codecs. Native sandboxing is not promised.
- Required versus optional sync? Recommendation: required for gameplay interpretation/config dependencies; optional cosmetic attachment replicas only when all consumers tolerate absence.
- Budgets and snapshot size? Only codec and frame limits apply to registered payloads. Size queue, decoding and assembly budgets to preserve that capacity; measure scheduling defaults with representative packs. Never silently truncate #796 values.
- Network-thread handlers? Recommendation: expose no mod network-thread handler initially; preserve main-thread Host transactions on both targets. Public counterpart differences need explicit review.
- Order against gameplay packets? The trace shows today's server runs a connection's mod messages after its gameplay packets of the same tick. Recommendation: deliver typed payloads at the packet's own place, as the design says, and test A=1, B=2, A=3 interleaved with a gameplay packet.
- Configuration on the main thread? Both sides finish configuration on a network thread today. Recommendation: stage 3 adds one bounded hand-off per side and makes completion wait for it, rather than running handlers on the connection thread.
Strings use a 4096 UTF-8 byte default, not a character count. A schema may pass
3N bytes to str_within for a vanilla N-character limit. Each box, list and
present option consumes one of 64 nesting levels; boxes charge their inner
allocation size before decoding.
Design: unified fluid declaration
Provisional proposal for #2436. Maintainer approval is required before implementation. No Rust changes land with this note.
Answer and reference behaviour
Use one Fluid declaration owned by horizon-mod-package::content, with an
explicit TankOnly or World(WorldFluid) placement mode and shared appearance.
Content.fluids becomes Vec<Fluid>. Files and native/wasm registration decode
into this same declaration and use one validation and publication pass. A client
model registration decorates an existing identity; it never declares a fluid.
Do not revive horizon-mods.
Reviewed upstream references: horizon-rust/horizon commit 73456ae0085f662006ce3ffe5866920be0d34e7a and horizon-rust/horizon commit 4584e2d7b5b3494f1cb024269cf510bca5f49863. The first declares template-based fluids with optional physics, block and bucket references, registers world fluids after vanilla, copies the liquid template, and makes level states resolve to the mod's own fluid. Its client draws the template; its tag masks widen to u128. The second moves the mod lookup behind vanilla water/lava and the level-property test, but still uses a lock and scan. Adopt template choice, validated physics and preservation of the mod's liquid block through spreading. Reject the second declaration owner, fixed-width mod membership masks, and hot-path linear lookup. Keep the fork's custom models and data-carrying kinds. Diverges from upstream: generated block and optional bucket are owned by the fluid declaration rather than independent block references.
Vanilla 26.3 was inspected through tools/rea_vanilla.py method: WaterFluid.getTickDelay returns 5;
LavaFluid.getTickDelay returns 10 for fast lava and 30 otherwise.
FlowingFluid.spread is the flow reference, not a declaration API. Preserve
vanilla timing, conversion, mixing and dimension-sensitive behaviour for vanilla
kinds; choosing a lava template must not classify a mod fluid as vanilla lava.
NeoForge's public fluid documentation and 26.3 BaseFlowingFluid Javadoc were
attempted at https://docs.neoforged.net/docs/resources/server/fluids/ and
https://aldak.netlify.app/javadoc/26.3.x/net/neoforged/neoforge/fluids/BaseFlowingFluid.html.
Both were inaccessible. The upstream declaration describes separate FluidType
properties and flowing-fluid properties, but this is not verified evidence of
NeoForge 26.3 behaviour. Exact viscosity effects, mixing, legal limits and
callback contracts remain unverified; require public documentation before
adopting a NeoForge contract. No NeoForge source or patches were read.
Proposed contract
Fluid contains a namespaced id, sprite/tint appearance and placement mode.
World mode selects typed Water or Lava template, tick delay, drop-off,
slope distance, source conversion, light level, viscosity and optional generated
bucket. Omitted timing uses the selected template, including fast-lava context;
explicit timing is fixed. Source conversion defaults false for mod fluids.
Viscosity is a positive metadata property, independent of tick delay and spread;
refuse an entity-motion viscosity capability until its implementation and public
contract are verified. Never advertise viscosity as working world drag merely
because its number decodes. Tank mode has no block, flowing registry entry,
bucket or scheduled ticks; world-only fields in tank mode are errors.
Proposed validation bounds: tick delay 1..=i32::MAX; drop-off 1..=7; slope search 1..=4 initially; light 0..=15; viscosity 1..=i32::MAX. These are Horizon policy, not claimed NeoForge limits. The slope bound preserves vanilla's maximum search capacity; reject upstream's extra 5..=8 pending a bounded search design. Water template defaults to tick delay 5, drop-off 1, slope 4 and light 0; lava light defaults 15. Resolve remaining lava rules from the existing vanilla context rather than flattening them into water defaults.
Registration returns named FluidDeclarationError reasons: InvalidId,
DuplicateOwner, NameCollision, InvalidRange, IncoherentPlacement,
UnsupportedCapability and Capacity. Include owner, id and field in diagnostics.
Validate all generated source/flowing/block/bucket names before publishing any.
Keep the fork's flowing-name convention (a:flowing_dir/oil for a:dir/oil),
reject collisions, and never silently rename it to upstream's last-segment rule.
The API-owned declaration, placement, template and appearance types each carry
an explicit #[derive(horizon_mod_api_macros::Wire)], using the shared Wire
mechanism rather than catalog-generated per-type serialization forms. This
follows crates/horizon-mod-vocab/src/world/game_rule_value.rs:7 and the derive
entry point at crates/horizon-mod-api-macros/src/lib.rs:6. The generator builds
host call tables only; engine structs stay out of the public API and ABI.
Before applying these derives in horizon-mod-package, move the shared Wire
contract and its supporting codec/layout primitives into a lower, API-owned
crate that depends on neither package nor API. Both crates depend directly on
that crate; mod_api::wire re-exports the same contract. Update the existing
derive to resolve that lower crate directly for package-owned declarations,
while retaining the same explicit derive mechanism for native and wasm. Do not
add a package-to-API dependency: the API already depends on the package
(crates/horizon-mod-api/Cargo.toml:15), and the current derive emits
::mod_api::wire (crates/horizon-mod-api-macros/src/wire.rs:49). This dependency
restructure is a prerequisite in stage 2, not a second serialization mechanism.
Explicit enum tags and bounded decoding remain requirements. Both targets
submit the same fields and receive the same refusal. Bound strings and list
sizes using existing package limits before allocation; reject malformed tags,
unknown fields, overflowing integers, NaN and infinity rather than clamp content.
No callback is needed for static declaration. Existing server fluid-rule hooks
retain a ServerHost; client model hooks retain ClientHost, and their panic
follows the existing dispatcher containment/abort policy, never unwinds through
wasm or publishes half a declaration.
Identity, lifecycle and routes
Retain the single FluidKind set (Empty, water/lava source and flowing,
Mod(ModFluid), ModFlowing(ModFluid)) and tick_id() mapping. Tank keys use the
existing fluid registry without fabricating world kinds. Saves use names, not
process indices. Use the existing content lifecycle holder for reload identity;
add no second generation or independent lifetime counter. Prepare immutable
resolved tables before publication; refuse removal while live references prevent
safe retirement. Retire old tables and emit journals only at root commit,
including lifted passes. Refuse saves during an open action savepoint.
Verified current owners (line numbers describe this checkout):
| Route | Owner and required change |
|---|---|
| File declarations, generated blocks/buckets | crates/horizon-mod-package/src/content.rs:509, :521, :565, :877: replace FluidFile/FluidLook split with one validated Fluid |
| Code declaration ingress | crates/horizon-mod-vocab/src/parts/fluids.rs:589, :634; crates/horizon-client/src/game/modding/api_values/content_declarations.rs:448: replace world-only registrar and conversion with unified input |
| Registration and merge | crates/horizon-client/src/mod_content.rs:95, :131, :157: remove independent merging/override semantics; one owner, one publication pass |
| Runtime identity and ticks | crates/horizon-world/src/fluid.rs:150, :210; crates/horizon-world/src/level/tick.rs:1815, :1864: retain kinds and tick matching |
| Flow, bucket placement and pickup | crates/horizon-world/src/level/fluids.rs:184, :312, :365: consume resolved template/rules and preserve own block |
| Client appearance | crates/horizon-client-render/src/fluid_models.rs:32, :53: index shared declaration appearance, retain model overrides |
| Save/cache/worldgen ticks | crates/horizon-client/src/save.rs:627; crates/horizon-world/src/level/data/cache.rs:629; crates/horizon-world/src/worldgen/generator_decoration.rs:874: retain name/tick conversions |
Single player, LAN and dedicated use the server simulation; remote clients only resolve synchronized content and render, never run authoritative flow callbacks. Use dense state-to-fluid and kind-to-rule tables resolved at load. No-listener flow stays O(1), with no mod scan, string creation, allocation or lock; vanilla water/lava retain their fast branch. Tags/predicates use resolved membership sets, not a second FluidKind or a u128 ceiling that truncates mod identities.
PR #2453 owns transaction tokens,
close validation and fluid tank write authorization in native_fluids.rs,
parts/fluids.rs and the transaction runtime/Wire forms. This proposal consumes
that authority check; it does not redesign markers, mint authority or duplicate
tank mutation paths. Declaration changes must preserve its guarded insert/extract
and rollback semantics, rebasing against whichever version lands first.
Acceptance matrix
| Scenario | Concrete expected result |
|---|---|
Tank m:oil | One tank key; zero blocks, buckets, flowing entries or ticks |
World m:brine, water defaults | Own liquid block and optional m:brine_bucket; delay 5, drop-off 1, slope 4, light 0 |
| World lava, normal context | Proposed template defaults: delay 30, drop-off 2, slope 2, light 15 |
| World lava, fast context | Proposed template defaults: delay 10, drop-off 1, slope 4, light 15 |
| Source conversion | Default false: two adjacent sources do not create a source; explicit true with the required support creates this mod identity, never vanilla water |
| Replacement | Preserve current mod-fluid refusal to be replaced; empty accepts flow; vanilla water accepts a different fluid only downward |
| Mixing | A lava-template mod keeps its own identity and does not acquire vanilla lava mixing solely from its template; vanilla water/lava mixing remains unchanged |
| Tag membership | A declared member matches its tag for source and flowing states; a nonmember does not; membership never truncates at 128 identities |
| Generated bucket | Placement creates the declared source block; pickup returns the declared bucket and removes that source; tank mode generates neither route |
| Client appearance | Shared sprite/tint supplies the default model; an explicit model override decorates the same identity without registering another fluid |
| World overrides | Delay 12, drop-off 2, slope 3, light 7 remain exactly 12/2/3/7 after native and wasm round trips |
| Invalid bounds | Delay 0, drop-off 8, slope 5, light 16, viscosity 0 each refuse with InvalidRange and zero publication |
| Unsupported viscosity motion | UnsupportedCapability; no silently ignored drag request |
| Source/flowing state | Level 0 resolves Mod; level 1 resolves ModFlowing for same identity; spread leaves own block |
| Vanilla timing | Water 5; lava normal 30, fast 10; no listeners produces unchanged results |
| Duplicate generated names | NameCollision; neither partial block nor bucket published |
| Tank-only plus world in one mod | Two Fluid entries, one validation pass, no second registration loop |
| Save/reload | Name round trip preserves identity despite changed process ordering; open savepoint refuses save |
| First upgrade or corrupt schema | Existing world with missing, unreadable or corrupt saved schema refuses before chunk loading or any write; original files remain byte-for-byte unchanged |
| New world schema | Persist the validated initial schema before allowing chunk publication or saves |
| Transaction abort | Tank insert followed by abort restores original amount; forged authority follows #2453 refusal |
Tag membership also requires replacing the current vanilla-only mask behavior
(crates/horizon-world/src/fluid.rs:258); custom membership is proposed here.
These are implementation acceptance requirements, not claims that the unified
API exists today. Current reference routes are
crates/horizon-world/src/level/fluids.rs:77 (timing), :189 (drop-off),
:199 (slope), :209 (conversion), :101 (replacement), :312 (container liquid
placement), :365 (source pickup), :750 (mixing), and
crates/horizon-client-render/src/fluid_models.rs:32 (appearance collection).
Port upstream's world-physics fixtures through the fork API: own-block spreading, source/flowing resolution, replacement and mixing, configurable timing/drop-off, light and template rendering. Hand-write expected states and amounts; compare native/wasm failures as well as successes. Audit upstream fixture expectations against vanilla before treating its NeoForge claims as an oracle.
Proposed stages (file none)
- Approve declaration shape, refusal policy and publicly verified capabilities.
- Move the shared Wire contract below package and API as specified above, then replace package and API declarations together; apply the shared explicit Wire derive to API-owned types for both native and wasm. Generate host call tables only, with no catalog-generated per-type Wire forms. Update every caller, sample, fixture, catalog note, docs/mods.md and affected modding book pages in that same implementation change.
- Resolve world/template/light/model tables and port the upstream physics tests. Preserve transaction ownership from #2453; add native/wasm acceptance fixtures.
Migration is a clean break: delete FluidLook and the separate world declaration entry point, rewrite existing content and samples to Fluid placement modes, and rebuild mods. No alias, shim, fallback parser, deprecated name or version gate.
Saved-world policy (proposed): mode and generated source/flowing/block/bucket
names form the persistent content schema. A mode change or generated-name change
requires a new world; refuse opening an existing world with that schema mismatch
before loading chunks or writing saves, and report the owner and changed names.
An existing world with a missing, unreadable or corrupt saved schema also
refuses before chunk loading or any write, including on its first upgrade to
this design. Never infer a baseline from the currently installed declarations.
Only a newly created world records the validated initial schema before chunk
publication or saves. Rebuilding mods does not migrate saved data. Implement an explicit saved-schema
check in the implementation stage; this is not an existing loader guarantee.
Current scheduled-fluid ticks serialize names and resolve them on load
(crates/horizon-client/src/save.rs:625, :632, :1982), so renaming cannot be
treated as a process-index change. In particular, read_ticks uses
filter_map and drops unresolved names (crates/horizon-client/src/save.rs:605,
:606); accepting a new baseline could therefore silently lose old ticks. Add acceptance fixtures for both mode changes
and name changes: refusal leaves the original save untouched. Add no alias,
fallback parser or automatic remapping.
Open questions
- What does viscosity promise beyond metadata? Recommend refusing motion support until the NeoForge 26.3 public contract and engine route are verified.
- Should slope search extend to eight? Recommend retaining four for this change; approve wider search only with an explicit capacity and cost review.
Design: worldgen features and placers
Provisional answer to #815: register feature implementations and placement modifiers once, decode their configured instances with the shared typed codec foundation, and execute both through one scoped target-access boundary. This is a proposal, not an implemented API or authority to close the issue. Structure-type registration remains separate.
Vanilla and NeoForge evidence
Horizon mirrors configured features, placed features and ordered placement modifiers. At the inspected base 0ef42f9e54, crates/horizon-world/src/worldgen/feature_graph.rs:245 creates one execution budget and passes the caller's random into modifiers and the selected feature; line 264 carries that shared run into nested placement. This is evidence about Horizon, not an independently verified vanilla contract.
Vanilla 26.3 was queried exclusively through cd /workspace/horizon && python3 tools/rea_vanilla.py method net.minecraft.world.level.levelgen.feature.ConfiguredFeature place: exact class unavailable. The subsequent classes ConfiguredFeature query returned zero matches with complete coverage. Consequently vanilla caller coverage, modifier laziness, random draw order, target clipping and capacity are unverified here. Resolve the pinned jar's actual names and obtain oracle fixtures before implementing; do not replace missing evidence with another project's source.
The issue names NeoForge 26.3 RegisterEvent as the counterpart. Requests for the public feature guide and 26.3 PlacedFeature Javadoc were inaccessible. Exact registration signatures, worker guarantees, codec limits and reload behavior are unverified. Require public documentation before adopting counterpart names or claiming full NeoForge equivalence. No NeoForge source, patches or other project's source were consulted.
Proposed registration and codec contract
Use one content-phase registration moment with the appropriate Host and scoped registrar, dependency ordered for both targets. Illustrative traits are FeatureType and PlacementModifierType; these are proposed names. A placer transforms one input position into an ordered stream, rather than replacing the feature runner. Freeze implementation declarations after mod loading. Reject duplicate ids, foreign namespace ownership, unresolved implementation references and declarations requiring unavailable target capabilities at registration. In particular, refuse native-only worker functions, arbitrary engine pointers, Game access, retained mutable state and guest callbacks without a worker execution adapter. Do not silently admit a declaration that will fail only when a chunk reaches it.
Reuse #2332's declaring-mod-owned typed schema, game-owned candidate decoding, complete candidate reference lookup and atomic publication. Use explicit #[derive(Wire)] and the shared generated layout, never a second encoding mechanism implied by the illustrative *Wire names. Define API-owned FeatureConfigWire, PlacementConfigWire, FeatureRefWire, PositionWire, BlockStateWire and WorldgenErrorWire through the shared generated codec foundation. These describe bounded immutable values, checked ids and structured errors, never Rust layout, opaque JSON transport, native pointers or guest addresses. Configuration codecs have no world writes, random, I/O or scheduling. Native and wasm decode the same schema into immutable worker configuration. Resolve nested configured and placed references, forward references and cycles before admission; unsupported recursion refuses the candidate.
Reuse the existing world lifecycle holder as snapshot identity and retirement authority. Do not add a second worldgen generation counter. Jobs retain the holder and immutable decoded graph; reload prepares everything before publishing through that holder. Failed decoding publishes nothing. Old jobs may finish only under their retained holder and must be rejected at installation if that holder is no longer current; never mix old configs with new block ids. Mod unload waits for dependent worlds/jobs to close. Data-only reload cannot replace callable implementations. Persist names, not resolved slots or worker handles.
Workers, target access and random
Decoration runs feature and modifier code on worldgen workers. Live growth and commands run the same adapters against their existing authoritative level target. Give callbacks a scoped worker-safe context following the structure context principles, not Game or a general ServerHost capable of gameplay services. The registration hook receives Host; worker execution needs a generated restricted Host/context contract reviewed alongside the structure boundary. A worker instance must be independently owned or reentrant with immutable shared configuration; never serialize every chunk behind one mutable mod instance. Wasm needs worker-local execution stores with the same callable and dependency semantics; inability to provide these refuses registration.
One target service owns block reads/writes, height and biome queries, metadata, nested placement and supported queued effects. It advertises actual readable region, writable region and vertical bounds. Outside reads return an explicit unavailable result; outside writes return false without loading chunks. No unbounded neighbor loads, direct live-world mutation, scheduling or retained target handle. Validate block identities against the held snapshot. Require read-your-writes and route block entities, ticks, neighbor effects and entities through the target's existing staging machinery; capabilities unavailable on either target must be refused explicitly. Structure piece chunk-box clipping is not automatically the feature contract.
In live lifted passes, callback writes, nested placement and apply share the root transaction. An inner success does not retire handles or emit journals: defer retirement and journal emission to root commit. Rollback discards staged writes and effects. An open action savepoint refuses saves, rather than persisting provisional state. Worker output stays private until accepted by the owning job; failed or stale output cannot install. Random is not rewound on an ordinary false result; failure policy must pin whether an aborted job restarts from its original seed.
Preserve decoration step/global feature-index seeding for each independently seeded root; inserting a mod must neither incidentally renumber vanilla roots nor consume their draws. The shared stream below is scoped to one root, not shared across independently seeded decoration roots. Borrow the caller's exact RNG. Modifier and feature draws can interleave: Horizon's current runner can execute the feature for the first position before a later modifier processes the second position (crates/horizon-world/src/worldgen/placement/chain.rs:215-260). Preserve duplicates and output order; the exact modifier/feature traversal and draw order remain subject to the pinned vanilla oracle. Nested features share the same stream; no callback reseeding, per-mod streams, eager random precomputation or parallel evaluation of one placement chain. No-listener execution preserves the built-in path. Native and wasm expose the same Java-compatible primitive draws and checked bounds; reject zero/negative next-int bounds before drawing.
Inspected route owners and required integration
These are verified source entry points at the base above, not proof of complete vanilla caller coverage. Every route must converge on the shared adapter; completion of the command, growth and worker caller trace is an implementation gate.
| Route | Verified owner and treatment |
|---|---|
| Worker biome decoration | crates/horizon-world/src/worldgen/generator_decoration.rs:388 owns decorate; use the job's held target and seeded random. |
| Placed graph and nested features | crates/horizon-world/src/worldgen/feature_graph.rs:245, :264, :811 own placed entry, nested run and feature entry; preserve shared budgets and random. |
| Modifier execution | crates/horizon-world/src/worldgen/placement.rs:313, :322 own modify/modify_in; stream custom results through the same ordered runner. |
| Named configured, inline, explicit definition and placed execution | crates/horizon-world/src/worldgen/live.rs:280, :310, :318, :338; share decoding and adapter dispatch, including nested references. |
| Command placement | crates/horizon-world/src/level/bonemeal.rs:131 owns place_feature; use the authoritative live target and its existing RNG. |
| Growth configured and placed routes | crates/horizon-world/src/level/growth.rs:291, :339 document the two placement paths; lift effects into the existing action transaction. |
| Catalog decoding | crates/horizon-world/src/worldgen/feature_catalog.rs:111 owns placed_feature; resolve custom implementations here through the shared candidate decoder. |
| Shared target | crates/horizon-world/src/worldgen/feature.rs:30 owns FeatureWorld; bridge this boundary rather than introducing a second block store. |
| Structure contexts | crates/horizon-mod-vocab/src/world/structure_type.rs owns GenerationContext and PieceContext; reuse principles, retain separate registration and behavior. |
Initial world load, datapack reload, remote world authority, worker cancellation and chunk installation must be traced through their owning lifecycle coordinator before coding. Remote clients never independently execute authoritative mod generation. This note does not assert uninspected file:line owners for those routes.
Untrusted input, failure and cost
Treat pack configuration, callback positions, states, reference graphs and wasm memory as untrusted. Validate lengths before allocation, checked coordinate arithmetic, finite scalar values, ids, depth and work budgets at the adapter boundary. Preserve vanilla's own capacity: never apply a smaller arbitrary custom cap to built-in placement, world height or legal neighbor reach. The current placed entry starts with 65,536 work units; that is a Horizon observation, not a verified vanilla maximum. Propose a separate configurable custom-work budget initially 65,536 units and depth 64, with structured refusal rather than truncation, subject to oracle capacity review. Stream positions so output cannot force an unbounded vector; charge nested calls and writes to the same custom budget.
Native unwind and wasm trap abort the current root action/job and discard staged effects; report mod, implementation and origin, and disable the failing callable under runtime policy. Propagate nested callback failures to the owning root, keeping nested successes provisional; subsequent calls to a disabled callable return an explicit structured failure rather than silently omitting terrain. Never continue with partially installed terrain. Native aborts cannot be caught, and native code cannot be forcibly fuel-limited; executable admission remains trusted. Wasm additionally uses existing memory/fuel limits. An ordinary feature false is successful execution with no success claim, not a panic or automatic rollback of vanilla-permitted writes.
Resolve ids once at load. The unused path adds at most an indexed variant dispatch, no mod scans, JSON parsing or per-block allocations. Custom execution costs scale with explicit queries, writes and emitted positions; wasm batching may reduce crossings only if it preserves lazy RNG and read-your-writes semantics. Measure allocations and boundary calls on empty, built-in and custom paths before claiming parity.
Acceptance matrix
Proposed expectations, not passing implementation tests. Run every applicable fixture on native and wasm, worker and live targets.
| Fixture | Concrete expected result |
|---|---|
| Test placer emits origin and origin+[1,0,0]; feature writes stone at each | Origin [0,64,0] produces exactly [0,64,0] and [1,64,0], in that order, two writes. |
| Placer emits duplicate origin twice | Two feature calls, two ordered attempts; no deduplication. |
| Callback draws nextInt(1), then built-in step within the same root | Callback gets 0; oracle pins the following built-in draw and coordinate independently for seed 0. Same-seed equality alone is insufficient. |
| Multiple positions with random draws in a later modifier and in the feature | Record the ordered modifier/feature draw trace, resulting coordinates and following RNG value against independent pinned vanilla oracle values; cover a feature call before the later modifier processes the second position. |
| Empty registration | Zero custom calls, decode allocations and mod scans; identical built-in coordinates and following RNG value to baseline. |
| Duplicate example:test or unsupported live-write capability | Registration refused before any world job; zero installed custom declarations for failed admission. |
| Reload valid config from count 2 to 3 | New jobs use 3; old jobs retain 2 and stale results install zero blocks. A subsequent failed reload retains the currently published count 3; new jobs still use 3. |
| Write at writable maximum and one beyond | Maximum accepted if legal; beyond returns false, zero chunk loads. Test negative and extreme i32 coordinates without overflow. |
| NaN scalar, invalid state, 65,537 custom work units, depth 65 | Structured failure, zero committed output; next valid job completes. Built-in capacity remains unchanged. |
| Native unwind / guest trap after one staged write | Zero committed writes, journals and retirement events. |
| Nested success then outer failure; save during open action | Zero committed effects; save refused. Root success emits each journal and retirement once. |
| Inline/named/nested/growth/command/decoration with same test config | Each resolves the same implementation; target-specific authority and RNG remain with the caller. |
Stage 1 evidence: the pinned 26.3 bytecode
Queried with tools/rea_vanilla.py against the pinned 26.3 client jar (checksum equal to research-lock.json's client_sha1), after the earlier attempt could not resolve the classes. The classes exist; the earlier query used a name this version does not have (ConfiguredFeature; configured features are folded into Feature). These are vanilla's shapes, read from the bytecode; what NeoForge 26.3 adds on top is still unread, because its public Javadoc was unreachable (HTTP 503) and its docs have no feature pages.
The API shapes in 26.3 differ from the 1.21-era ones the illustrative names assumed:
Featureis not generic over a configuration and has noFeaturePlaceContext. Its configuration is part of the feature itself (the JSON has the settings inline in the root object next totype). It hasplace(WorldGenLevel, ChunkGenerator, RandomSource, BlockPos): booleanandcodec(): MapCodec<? extends Feature>.PlacedFeatureis(Holder<Feature>, List<PlacementModifier>), withplace(WorldGenLevel, ChunkGenerator, RandomSource, BlockPos): boolean. It makes aFeaturePlacerand calls it.PlacementModifierhas nogetPositionsstream and there is noPlacementModifierTypeclass. A modifier ismodify(PlacementContext, RandomSource, BlockPos, Consumer<BlockPos>): void: it pushes each output position to the consumer, and hascodec(): MapCodec<? extends PlacementModifier>.PlacementContextoffersgetLevel,generator,getBlockState(BlockPos),getHeight(Heightmap.Types, int, int),getMinYandtopFeature(): Optional<PlacedFeature>.
Traversal and draw order (FeaturePlacer.place, overload with biomeCheck):
- With no modifiers, the feature places once at the origin.
- Otherwise a stack of
(position, modifier index)starts with the origin at index 0. Each step pops the last pair, calls that modifier'smodifywith the sharedRandomSource, and collects its outputs in order. - If another modifier follows, the outputs are pushed in reverse order at the next index, so the first output is popped next: depth first.
- If it was the last modifier, the feature places at each output now, in output order, before the next stack pop.
placedAnyis the OR of the results. - Every modifier and feature draws from the same
RandomSource, in this order. Nothing is deduplicated, and a draw is never undone for a false result.
Horizon's crates/horizon-world/src/worldgen/placement/chain.rs (place_control) follows exactly this: a pending stack popped from the end, outputs extended in reverse, features run on the last level's outputs in order. So the interleaving the acceptance matrix asks about (a feature call for the first position before a later modifier processes the second) is vanilla's behaviour and is already Horizon's.
Callers. FeaturePlacer.placeWithBiomeCheck is called only from ChunkGenerator.applyBiomeDecoration (decoration, with the biome check). PlacedFeature.place is called from GrassBlock.placeBonemealEffect, CoralClawFeature, CoralTreeFeature, RandomBooleanSelectorFeature, RandomSelectorFeature, SimpleRandomSelectorFeature, WeightedPlacedFeature, WeightedRandomSelectorFeature, RootSystemFeature (placeDirtAndTree), SingleBlockPillarFeature, VegetationPatchFeature (placeVegetation) and FeaturePoolElement (structure pools): the nested and growth routes the acceptance matrix lists. The command route is its own query: PlaceCommand.placeFeature(source, Holder<Feature>, pos) calls Feature.place(level, level.getChunkSource().getGenerator(), level.getRandom(), pos) directly, with the level's own random, no placement modifiers and no biome check, and fails the command when place returns false. So the command route runs a feature on its own and a placed feature (modifiers and all) is not a command target; the custom feature API must therefore expose the feature call separately from the placement chain.
Numeric draws, run against the jar. A Java program run against the pinned client.jar printed these vanilla values, and crates/horizon-core/src/random/worldgen/oracle_tests.rs asserts them for Horizon's WorldgenRandom: from seed 0 nextInt(1) is 0 and still consumes one draw, so the next two nextInt(16) are 13 and 3 and the following nextLong is -7261648964369397258; setDecorationSeed(0, 0, 0) is 0 and setFeatureSeed(0, 0, 0) gives nextInt(16) 11 then 13; setDecorationSeed(0, 16, -32) is -31007559772603472 and setFeatureSeed(.., 3, 2) gives 10 then 4. Those pin the callback draw and the global feature-index and step seeding. The Xoroshiro root is pinned the same way (run with Mojang's DataFixerUpper, fastutil, Guava and SLF4J on the class path): XoroshiroRandomSource(0) gives nextInt(1) 0, then nextInt(16) 2 and 2; the WorldgenRandom over it gives 0, then 12 and 4; setDecorationSeed(0, 16, -32) is -7422581477381670832 and setFeatureSeed(.., 3, 2) gives 4 then 9. The draws of a whole placement chain remain to be pinned by a run of a real feature. The stream signature can now be fixed as the push-based modify above instead of a position stream, but this API shape alone does not eliminate output-list allocations: vanilla FeaturePlacer and Horizon chain.rs still collect modifier outputs. Allocation changes require separate measurement.
Proposed stages
No Rust work is authorized by this note. These stages are implementation gates, not demonstrated behavior:
- (Callers and shapes resolved above; the numeric fixtures remain.) Build independent oracle fixtures for lazy modifier traversal, duplicates, primitive draws and capacity before freezing a stream signature. Pin a draw following
nextInt(1)within the same root, and separately verify decoration step/global feature-index seeding and unchanged vanilla roots after mod insertion. - Settle the shared Wire and lifecycle contracts with #811/#2332. Specify object-safe registrar and execution signatures, API-owned IDs, errors and positions, and callback-scoped target access that cannot escape. Require explicit
#[derive(Wire)], the shared layout, and named load-time refusal of unsupported types and capabilities on both targets; registrar names remain illustrative pending public 26.3 evidence. - Generate registration and execution adapters together for native and wasm. Keep phase-limited registration authority separate from a restricted generated worker Host over captured registries and the scoped target, with no ServerHost or Game authority. Review its method set against existing worker-safe colour and structure paths. Prove worker-local wasm stores preserve native dependency linkage and callable semantics before admission.
- Integrate target staging and every live/worker route under one root owner. Prove nested successes remain provisional, stale worker output cannot install, and disabled callables fail explicitly on subsequent calls. Distinguish ordinary false with vanilla-permitted writes from panic/trap rollback.
- Run the acceptance matrix with independent expected values, adversarial fixtures and hot-path measurements on both targets. Obtain public 26.3 registration evidence and retain the explicit limits on equivalence claims until verified.
Open questions
- Exact NeoForge contract and limits? Recommendation: require public 26.3 documentation before adopting names or limits; retain unverified status where silent.
- Exact vanilla modifier traversal and RNG values? Recommendation: oracle the actual pinned classes and all callers before freezing the stream interface.
- Which effects and neighbor access must both targets support? Recommendation: publish a capability table and refuse unsupported registration; preserve verified vanilla region capacity.
- Worker Host integration and wasm concurrency? Recommendation: review one restricted generated context with structure workers, then prove worker-local stores and dependency linkage before admission.
- Reload and installation owner? Recommendation: trace the existing lifecycle holder through world entry, cancellation and installation; use its identity and root commit, never another generation allocator.
- Custom budget policy? Recommendation: keep budgets explicit and configurable, measure real packs and verify vanilla capacity before freezing defaults.
- Native callback failure recovery? Recommendation: fail the owning root action/job atomically, disable the callable and require explicit recovery; document unrecoverable process aborts.
Farmer's Delight's calls against existing hooks
The open rows of the Portizon map of Farmer's Delight's mixins (#1027; the full 34-point table is
reservedbytes' comment on that issue), each exercised against the hook Horizon already has. Each
row names what Farmer's Delight's injection does at its vanilla method, the hook's contract and the
test that runs it. No Farmer's Delight-specific hook is proposed. The three contract mismatches the
map found have their own issues: can_eat changing the answer (#1897), the attack strength at the
blow (#1898) and the dispenser's behaviour by the facing block (#1899).
Prefix API = crates/horizon-mod-vocab/src/; Game = crates/horizon-client/src/game/.
| Farmer's Delight's injection | Hook and contract | Status | Evidence |
|---|---|---|---|
CampfireBlock#isSmokeSource, @Inject at HEAD, cancellable: answers for its own blocks. | BlockPart::smoke_source (API hooks/base.rs:3327): replace, scoped to the block under the campfire, asked on an authoritative server as the campfire is placed or that block changes. | Works | Game modding/native_smoke_source_tests.rs:44: a Part's yes makes a campfire over stone a signal fire, and a Part's no over a hay bale replaces vanilla's yes, through the shape update when the block under it changes. The map's location note (only the hook's rustdoc names the method) is settled: horizon-world level/survive.rs:504 is CampfireBlock.isSmokeSource and asks the hook. |
LivingEntity#hurtServer, @ModifyVariable at HEAD: changes the damage amount. | living_incoming_damage (API hooks/base.rs:906): cancel, with container.set_new_damage; fires past hurtServer's guards. | Works | Game modding/native_damage_tests.rs:119: a listener sets 4 on a 10-point hit and the pig keeps 6 health, so the changed amount is the one dealt. The map's "contract mismatch" read the hook's name (cancel), not its mutable container. Farmer's Delight changes the amount before the guards, and the hook runs after them. In vanilla 26.3's hurtServer, the guards (isInvulnerableTo, isDeadOrDying, fire damage under Fire Resistance) return before the method first reads the amount, so the dealt damage is the same. |
LivingEntity#knockback (both overloads), @ModifyVariable at HEAD, ordinal 0: changes the strength. | living_knockback (API hooks/base.rs:2069): cancel, with mutable strength, x and z on the raw strength before knockback resistance. | Works | Game modding/native_knockback_tests.rs:91: a listener doubles the strength (:42) and the push the game applies carries 0.8 for 0.4 (:142). The map's "contract mismatch" read the hook's name, as above. |
MinecraftServer#onGameRuleChanged, @Inject at HEAD: is told a rule changed. | game_rule_changed (API hooks/game_rule_changed.rs:15): listen, with the rule, the new value and the actor. | Works | Game modding/native_level_world.rs:523: setting keep_inventory tells a listener the rule, that no player caused it, and the whole rule set with the new value. The map found no candidate, because it tagged the method to two Hub setters rather than to the hook. |
Cake eligibility
can_eat also supplies cake and candle cake's Player.canEat(false) answer.
The event receives the currently held stack, or None for an empty
hand, not a synthetic cake item. Answer Some(false) to refuse a bite even when
hungry, or Some(true) to allow a full player to eat. Leaving the answer unset
keeps vanilla hunger and creative-mode behavior.
Gaps
The outcome tests in this table do not establish exact function-entry fidelity. In particular,
listening once to living_knockback is not equivalent to injecting both delegated overloads;
#2753 tracks the entry pair and #2767 tracks its cross-target API admission. For hearing refused
damage attempts without changing the amount, use the existing
invulnerability observation, with its explicit vanilla exclusions (#2752).
living_incoming_damage already allows amount changes before the cooldown comparison; no new
amount-bearing entry event is needed for that use case.
Generated block class tags
Attach with block_part_tag("horizon:class/fence", part). World open and successful /reload generate these reserved tags from actual class chains, including template mod blocks and recognized ancestors. State variants deduplicate by block key. block includes the root class. Typed tag reads use the same snapshot. Datapack definitions under horizon:class/ are ignored with a warning; ordinary tags remain independent. Failed reload keeps the installed snapshot. Generation costs O(states × chain length) during preparation; dispatch adds no tick work.
| Tag | Vanilla class |
|---|---|
horizon:class/block | Block |
horizon:class/air | AirBlock |
horizon:class/amethyst_cluster | AmethystClusterBlock |
horizon:class/anvil | AnvilBlock |
horizon:class/attached_stem | AttachedStemBlock |
horizon:class/azalea | AzaleaBlock |
horizon:class/bamboo_sapling | BambooSaplingBlock |
horizon:class/bamboo_stalk | BambooStalkBlock |
horizon:class/abstract_banner | AbstractBannerBlock |
horizon:class/banner | BannerBlock |
horizon:class/wall_banner | WallBannerBlock |
horizon:class/barrel | BarrelBlock |
horizon:class/base_coral_plant_type | BaseCoralPlantTypeBlock |
horizon:class/base_coral_plant | BaseCoralPlantBlock |
horizon:class/base_coral_fan | BaseCoralFanBlock |
horizon:class/base_coral_wall_fan | BaseCoralWallFanBlock |
horizon:class/coral_plant | CoralPlantBlock |
horizon:class/coral_fan | CoralFanBlock |
horizon:class/coral_wall_fan | CoralWallFanBlock |
horizon:class/coral | CoralBlock |
horizon:class/base_entity | BaseEntityBlock |
horizon:class/base_fire | BaseFireBlock |
horizon:class/fire | FireBlock |
horizon:class/soul_fire | SoulFireBlock |
horizon:class/base_pressure_plate | BasePressurePlateBlock |
horizon:class/pressure_plate | PressurePlateBlock |
horizon:class/weighted_pressure_plate | WeightedPressurePlateBlock |
horizon:class/base_rail | BaseRailBlock |
horizon:class/rail | RailBlock |
horizon:class/powered_rail | PoweredRailBlock |
horizon:class/detector_rail | DetectorRailBlock |
horizon:class/base_torch | BaseTorchBlock |
horizon:class/torch | TorchBlock |
horizon:class/wall_torch | WallTorchBlock |
horizon:class/redstone_torch | RedstoneTorchBlock |
horizon:class/redstone_wall_torch | RedstoneWallTorchBlock |
horizon:class/abstract_bed | AbstractBedBlock |
horizon:class/bed | BedBlock |
horizon:class/straw_bed | StrawBedBlock |
horizon:class/beehive | BeehiveBlock |
horizon:class/beetroot | BeetrootBlock |
horizon:class/bell | BellBlock |
horizon:class/big_dripleaf | BigDripleafBlock |
horizon:class/big_dripleaf_stem | BigDripleafStemBlock |
horizon:class/bonemealable_feature_placer | BonemealableFeaturePlacerBlock |
horizon:class/bubble_column | BubbleColumnBlock |
horizon:class/budding_amethyst | BuddingAmethystBlock |
horizon:class/bush | BushBlock |
horizon:class/button | ButtonBlock |
horizon:class/cactus | CactusBlock |
horizon:class/cactus_flower | CactusFlowerBlock |
horizon:class/cake | CakeBlock |
horizon:class/campfire | CampfireBlock |
horizon:class/abstract_candle | AbstractCandleBlock |
horizon:class/candle | CandleBlock |
horizon:class/candle_cake | CandleCakeBlock |
horizon:class/carpet | CarpetBlock |
horizon:class/wool_carpet | WoolCarpetBlock |
horizon:class/mossy_carpet | MossyCarpetBlock |
horizon:class/carrot | CarrotBlock |
horizon:class/carved_pumpkin | CarvedPumpkinBlock |
horizon:class/abstract_cauldron | AbstractCauldronBlock |
horizon:class/cauldron | CauldronBlock |
horizon:class/lava_cauldron | LavaCauldronBlock |
horizon:class/layered_cauldron | LayeredCauldronBlock |
horizon:class/growing_plant | GrowingPlantBlock |
horizon:class/growing_plant_head | GrowingPlantHeadBlock |
horizon:class/growing_plant_body | GrowingPlantBodyBlock |
horizon:class/cave_vines | CaveVinesBlock |
horizon:class/cave_vines_plant | CaveVinesPlantBlock |
horizon:class/kelp | KelpBlock |
horizon:class/kelp_plant | KelpPlantBlock |
horizon:class/twisting_vines | TwistingVinesBlock |
horizon:class/twisting_vines_plant | TwistingVinesPlantBlock |
horizon:class/weeping_vines | WeepingVinesBlock |
horizon:class/weeping_vines_plant | WeepingVinesPlantBlock |
horizon:class/sign | SignBlock |
horizon:class/standing_sign | StandingSignBlock |
horizon:class/wall_sign | WallSignBlock |
horizon:class/ceiling_hanging_sign | CeilingHangingSignBlock |
horizon:class/wall_hanging_sign | WallHangingSignBlock |
horizon:class/rotated_pillar | RotatedPillarBlock |
horizon:class/chain | ChainBlock |
horizon:class/abstract_chest | AbstractChestBlock |
horizon:class/chest | ChestBlock |
horizon:class/trapped_chest | TrappedChestBlock |
horizon:class/copper_chest | CopperChestBlock |
horizon:class/ender_chest | EnderChestBlock |
horizon:class/chiseled_book_shelf | ChiseledBookShelfBlock |
horizon:class/chorus_flower | ChorusFlowerBlock |
horizon:class/chorus_plant | ChorusPlantBlock |
horizon:class/pipe | PipeBlock |
horizon:class/cocoa | CocoaBlock |
horizon:class/falling_block | FallingBlock |
horizon:class/colored_falling | ColoredFallingBlock |
horizon:class/sand | SandBlock |
horizon:class/concrete_powder | ConcretePowderBlock |
horizon:class/dragon_egg | DragonEggBlock |
horizon:class/comparator | ComparatorBlock |
horizon:class/repeater | RepeaterBlock |
horizon:class/diode | DiodeBlock |
horizon:class/composter | ComposterBlock |
horizon:class/copper_bulb | CopperBulbBlock |
horizon:class/crafter | CrafterBlock |
horizon:class/crafting_table | CraftingTableBlock |
horizon:class/creaking_heart | CreakingHeartBlock |
horizon:class/crop | CropBlock |
horizon:class/cross_collision | CrossCollisionBlock |
horizon:class/daylight_detector | DaylightDetectorBlock |
horizon:class/decorated_pot | DecoratedPotBlock |
horizon:class/directional | DirectionalBlock |
horizon:class/dispenser | DispenserBlock |
horizon:class/dropper | DropperBlock |
horizon:class/door | DoorBlock |
horizon:class/double_plant | DoublePlantBlock |
horizon:class/dried_ghast | DriedGhastBlock |
horizon:class/drop_experience | DropExperienceBlock |
horizon:class/dry_vegetation | DryVegetationBlock |
horizon:class/short_dry_grass | ShortDryGrassBlock |
horizon:class/tall_dry_grass | TallDryGrassBlock |
horizon:class/end_portal_frame | EndPortalFrameBlock |
horizon:class/rod | RodBlock |
horizon:class/end_rod | EndRodBlock |
horizon:class/lightning_rod | LightningRodBlock |
horizon:class/flower | FlowerBlock |
horizon:class/eyeblossom | EyeblossomBlock |
horizon:class/wither_rose | WitherRoseBlock |
horizon:class/face_attached_horizontal_directional | FaceAttachedHorizontalDirectionalBlock |
horizon:class/farmland | FarmlandBlock |
horizon:class/fence | FenceBlock |
horizon:class/fence_gate | FenceGateBlock |
horizon:class/firefly_bush | FireflyBushBlock |
horizon:class/flower_bed | FlowerBedBlock |
horizon:class/flower_pot | FlowerPotBlock |
horizon:class/frogspawn | FrogspawnBlock |
horizon:class/ice | IceBlock |
horizon:class/frosted_ice | FrostedIceBlock |
horizon:class/abstract_furnace | AbstractFurnaceBlock |
horizon:class/glazed_terracotta | GlazedTerracottaBlock |
horizon:class/multiface | MultifaceBlock |
horizon:class/multiface_spreadeable | MultifaceSpreadeableBlock |
horizon:class/glow_lichen | GlowLichenBlock |
horizon:class/sculk_vein | SculkVeinBlock |
horizon:class/snowy | SnowyBlock |
horizon:class/spreading_snowy | SpreadingSnowyBlock |
horizon:class/grass | GrassBlock |
horizon:class/mycelium | MyceliumBlock |
horizon:class/grindstone | GrindstoneBlock |
horizon:class/half_transparent | HalfTransparentBlock |
horizon:class/hanging_moss | HangingMossBlock |
horizon:class/hanging_roots | HangingRootsBlock |
horizon:class/hay | HayBlock |
horizon:class/hopper | HopperBlock |
horizon:class/horizontal_directional | HorizontalDirectionalBlock |
horizon:class/huge_mushroom | HugeMushroomBlock |
horizon:class/infested | InfestedBlock |
horizon:class/infested_rotated_pillar | InfestedRotatedPillarBlock |
horizon:class/iron_bars | IronBarsBlock |
horizon:class/stained_glass_pane | StainedGlassPaneBlock |
horizon:class/jukebox | JukeboxBlock |
horizon:class/ladder | LadderBlock |
horizon:class/lantern | LanternBlock |
horizon:class/leaf_litter | LeafLitterBlock |
horizon:class/leaves | LeavesBlock |
horizon:class/mangrove_leaves | MangroveLeavesBlock |
horizon:class/lectern | LecternBlock |
horizon:class/lever | LeverBlock |
horizon:class/light | LightBlock |
horizon:class/lily_pad | LilyPadBlock |
horizon:class/liquid | LiquidBlock |
horizon:class/loom | LoomBlock |
horizon:class/magma | MagmaBlock |
horizon:class/mangrove_propagule | MangrovePropaguleBlock |
horizon:class/mangrove_roots | MangroveRootsBlock |
horizon:class/moving_piston | MovingPistonBlock |
horizon:class/mud | MudBlock |
horizon:class/mushroom | MushroomBlock |
horizon:class/nether_fungus | NetherFungusBlock |
horizon:class/nether_portal | NetherPortalBlock |
horizon:class/nether_roots | NetherRootsBlock |
horizon:class/nether_sprouts | NetherSproutsBlock |
horizon:class/nether_wart | NetherWartBlock |
horizon:class/note | NoteBlock |
horizon:class/nylium | NyliumBlock |
horizon:class/observer | ObserverBlock |
horizon:class/path | PathBlock |
horizon:class/abstract_skull | AbstractSkullBlock |
horizon:class/skull | SkullBlock |
horizon:class/wall_skull | WallSkullBlock |
horizon:class/piston_base | PistonBaseBlock |
horizon:class/piston_head | PistonHeadBlock |
horizon:class/pitcher_crop | PitcherCropBlock |
horizon:class/speleothem | SpeleothemBlock |
horizon:class/pointed_dripstone | PointedDripstoneBlock |
horizon:class/sulfur_spike | SulfurSpikeBlock |
horizon:class/potato | PotatoBlock |
horizon:class/powder_snow | PowderSnowBlock |
horizon:class/powered | PoweredBlock |
horizon:class/pumpkin | PumpkinBlock |
horizon:class/red_stone_ore | RedStoneOreBlock |
horizon:class/redstone_lamp | RedstoneLampBlock |
horizon:class/redstone_wire | RedstoneWireBlock |
horizon:class/respawn_anchor | RespawnAnchorBlock |
horizon:class/rooted_dirt | RootedDirtBlock |
horizon:class/sapling | SaplingBlock |
horizon:class/scaffolding | ScaffoldingBlock |
horizon:class/sculk_sensor | SculkSensorBlock |
horizon:class/calibrated_sculk_sensor | CalibratedSculkSensorBlock |
horizon:class/sculk_shrieker | SculkShriekerBlock |
horizon:class/sea_pickle | SeaPickleBlock |
horizon:class/seagrass | SeagrassBlock |
horizon:class/shelf | ShelfBlock |
horizon:class/shelf_mushroom | ShelfMushroomBlock |
horizon:class/shulker_box | ShulkerBoxBlock |
horizon:class/slab | SlabBlock |
horizon:class/small_dripleaf | SmallDripleafBlock |
horizon:class/sniffer_egg | SnifferEggBlock |
horizon:class/snow_layer | SnowLayerBlock |
horizon:class/soul_sand | SoulSandBlock |
horizon:class/sponge | SpongeBlock |
horizon:class/wet_sponge | WetSpongeBlock |
horizon:class/spore_blossom | SporeBlossomBlock |
horizon:class/stair | StairBlock |
horizon:class/stem | StemBlock |
horizon:class/stonecutter | StonecutterBlock |
horizon:class/sugar_cane | SugarCaneBlock |
horizon:class/sweet_berry_bush | SweetBerryBushBlock |
horizon:class/tall_flower | TallFlowerBlock |
horizon:class/tall_grass | TallGrassBlock |
horizon:class/tall_seagrass | TallSeagrassBlock |
horizon:class/target | TargetBlock |
horizon:class/tnt | TntBlock |
horizon:class/torchflower_crop | TorchflowerCropBlock |
horizon:class/trap_door | TrapDoorBlock |
horizon:class/trip_wire | TripWireBlock |
horizon:class/trip_wire_hook | TripWireHookBlock |
horizon:class/turtle_egg | TurtleEggBlock |
horizon:class/vegetation | VegetationBlock |
horizon:class/vine | VineBlock |
horizon:class/wall | WallBlock |
horizon:class/waterlogged_transparent | WaterloggedTransparentBlock |
horizon:class/weathering_copper_full | WeatheringCopperFullBlock |
horizon:class/web | WebBlock |
horizon:class/copper_golem_statue | CopperGolemStatueBlock |
horizon:class/beacon | BeaconBlock |
horizon:class/brewing_stand | BrewingStandBlock |
horizon:class/brushable | BrushableBlock |
horizon:class/conduit | ConduitBlock |
horizon:class/enchanting_table | EnchantingTableBlock |
horizon:class/end_gateway | EndGatewayBlock |
horizon:class/end_portal | EndPortalBlock |
horizon:class/spawner | SpawnerBlock |
horizon:class/trial_spawner | TrialSpawnerBlock |
horizon:class/vault | VaultBlock |
horizon:class/barrier | BarrierBlock |
horizon:class/structure_void | StructureVoidBlock |
horizon:class/command | CommandBlock |
horizon:class/structure | StructureBlock |
horizon:class/jigsaw | JigsawBlock |
horizon:class/test_block | TestBlock |
horizon:class/test_instance_block | TestInstanceBlock |
horizon:class/smithing_table | SmithingTableBlock |
horizon:class/cartography_table | CartographyTableBlock |
horizon:class/netherrack | NetherrackBlock |
horizon:class/sculk | SculkBlock |
horizon:class/sculk_catalyst | SculkCatalystBlock |
horizon:class/potent_sulfur | PotentSulfurBlock |
horizon:class/honey | HoneyBlock |
horizon:class/slime | SlimeBlock |
horizon:class/heavy_core | HeavyCoreBlock |
horizon:class/crying_obsidian | CryingObsidianBlock |
Item abilities
An item's Part can change the answer to an ability question. The hook receives a read-only Host. Keep answers pure functions of the stack and ability so the client and server agree.
#[horizon_part]
impl ItemPart for CarvingTool {
fn can_perform_action(&self, _: &dyn Host, event: &mut CanPerformActionEvent<'_>) {
if event.ability == "minecraft:shears_carve" {
event.can_perform = true;
}
}
}
Attach this Part to your item with the registrar's item_part method. This grants pumpkin carving only: minecraft:shears_harvest is a separate ability for mob and dispenser shearing. minecraft:shield_block permits blocking and uses the stack's blocking component, or the default component when absent. Leave unknown abilities unchanged. Tool abilities and other excluded consumers are not implemented; this is partial coverage, not a promise that every item-kind check is an ability query.
Mod render colour
The adopted rule (#1498) is shared by mod pipelines, mod passes and shader packs:
Colours are linear light, 1.0 is vanilla's white at paper white, and values above 1 are allowed. Horizon's generated entry code converts to and from the frame, as it does for 0.2 pack stages, so mod shader code only sees linear light. Mod code never assumes the frame's format and leaves the frame's alpha to the renderer. #869's typed configuration doesn't offer the colour format.
This is a Horizon contract, with no NeoForge counterpart. The upstream basis is horizon-rust/horizon#9600 (HDR), horizon-rust/horizon#9930 (shader API 0.2) and horizon-rust/horizon#10040 (serving 0.2).
What exists today
The shared rule uses BT.709 primaries and vanilla's power-2.2 decoding, not the
piecewise sRGB curve. The conversion routes are specified in
docs/design/hdr-linear-light.md:54: material albedo, tint, lightmap and coloured
light; sky, fog and weather colours; block textures; and scene, previous,
history, opaque, reflection, shadow-colour and pack-target colour textures.
Depth, normals, light levels, emission and LabPBR channels stay data. Custom
noise textures also stay data (docs/api/shaders.md:171).
The existing pack generator supplies the conversion helpers in
crates/horizon-shader-api/src/stages.rs:133, frame-storage conversion at
:510, and linear post-chain entry code at :610. Mod pipelines (#884,
Drawing with your own render pipeline) reuse the
helpers at :133 in their generated entry points; mod passes (#867, #869) do not
exist yet. The current
experimental serving limits are in docs/api/shaders.md:163: 0.2 needs
HORIZON_HDR_FRAME=float; linear frame blending is not yet served.
Instance inputs remain vanilla-space inputs: ModelInstance::tint is packed
0xAARRGGBB, and block and sky light are each 0..=15
(crates/horizon-mod-vocab/src/client/model_instance.rs:1). The engine owns their
conversion. Do not pre-decode these inputs just because shader colours are
linear. R2 instance drawing (#808) must preserve this distinction.
WorldQuad::color remains a u32 ARGB colour with 8-bit channels, including
coverage alpha (crates/horizon-mod-vocab/src/client/world_draw.rs:40). It cannot express
RGB above 1; HDR-range quad colours would need a later typed addition.
Applying the rule
The planned R5 work (#884, #867, #869 and #889) must use the same generated entry/exit boundary for mod pipelines and passes. Material, sky, weather and scene-texture colour must arrive linear and leave linear; data textures must not acquire colour decoding. Coexistence with a selected shader pack must not introduce a second decode, encode or mod-specific colour convention.
For example, neutral shader RGB (1.0, 1.0, 1.0) means paper white. RGB
(2.0, 2.0, 2.0) means twice that linear light. In a gamma-stored float frame,
the generated exit stores about 1.37035 for linear 2.0 (power 1/2.2), and
the next generated colour entry restores 2.0. In linear storage it stays
2.0. White stays 1.0 in either case. Encoded vanilla 0.5 becomes about
0.21763764 in shader code; depth 0.5 stays 0.5. These are boundary examples,
not a promise that an SDR display or an 8-bit target can retain values above
white. Output mapping and storage limits belong to the renderer.
SDR/HDR switches, float-target selection and target/pipeline recreation must
preserve this shader contract. Resource reload must rebuild affected resources
and generated boundaries together. Mods must not cache a presumed frame format.
GUI colours and alpha compositing also remain renderer-owned: vanilla-space
GUI inputs are not automatically linear shader inputs. Coverage supplied by a
draw is distinct from permission to write the frame's alpha. A mod pass cannot
use that alpha as private storage; 0.2 post alpha is ignored. The HDR GUI
layering plan is in docs/design/hdr-linear-light.md, under “The HUD”.
Review checklist
For #884, #867, #869, #889 and R2 instance drawing:
- Check material, sky, weather and sampled scene colour at both generated boundaries; check that data textures remain data.
- Exercise white
1.0, bright2.0, vanilla tint/light and 8-bit ARGB separately. - Check pack coexistence, resource reload and SDR/HDR/float target recreation without stale pipelines or duplicate conversion.
- Check translucent world draws and GUI coverage over bright content; keep frame alpha owned by the renderer and configuration format-free.
Open question for those implementations: exact pass ordering and reload invalidation APIs are not settled by this colour rule. Recommendation: resolve those in the typed R5 contracts, reuse the renderer's resource lifetime, and keep the colour boundary invariant across every supported ordering.
Observing refused damage attempts
entity_invulnerability_check observes an attempted server hit without changing its amount.
It supplies the entity, damage source, original invulnerability decision and a writable decision.
A listener that only plays a sound leaves the decision unchanged. There is no amount: vanilla's
isInvulnerableTo takes the level and damage source, not damage. Amount mutation belongs on
living_incoming_damage, which runs after the early living guards but before the hurt-cooldown
comparison.
For player hits, the observation is delivered once across the server-player, player and living predicates and the paused equipment continuations. The base decision is retained for that attempt; the surrounding predicates still apply their own PvP, ability, gamerule, death and client-loaded guards. A new hit, including one refused by cooldown, starts a new observation. Creative and PvP-refused player wrappers do not suppress it. Ordinary mob hits also deliver it before death, fire resistance, shield and cooldown refusal. Ghast and warden overrides deliver their complete invulnerability decision, including flagged ghasts, reflected fireballs and digging wardens.
Zero-damage snowball, egg, ender-pearl and custom thrown collisions still enter the player
predicate before the zero-damage refusal. A successfully teleported player's pearl also attempts
its separate fall damage when a listener changes that damage to zero. Wither-skull impact and
its subsequent explosion are separate attempts. /damage asks the predicate even for spectators.
Firework damage to the attached entity is a direct attempt, including creative, spectator or dead targets. Its splash query excludes spectators, but includes creative, dead and not-yet-loaded resident players and dead resident mobs. Ability, death and client-loaded refusals occur inside the damage pipeline, after the observation.
A damage-generating precondition is not itself an invulnerability query. In particular:
- Poison at health at or below one does not call vanilla
hurtServerand produces no observation. - Spectators are absent from vanilla's explosion entity query; team damage restrictions can also prevent an explosion damage call. Neither generates an observation. Creative players remain in the query and their attempted explosion damage is observed before ability refusal.
- Falling-block damage selects only living, alive, non-creative, non-spectator entities. Evoker fangs select living, alive entities. Pufferfish contact comes from the player's alive, non-spectator contact selection. Targets omitted by those vanilla queries have no attempt.
- A sulfur cube with a body item can consume body-immune damage in its
hurtServeroverride, performing its special push and returning without entering the living invulnerability predicate. That path produces no observation, matching the absence of a vanilla predicate call. Ordinary sulfur cube damage that enters the living pipeline is observed. - Client-side prediction, missing targets and unknown resident damage-source identifiers do not create authoritative server hit attempts.
The remaining selected non-living paths (armor stands, items, vehicles and experience orbs) retain the coverage stated in the hook's reference documentation. A separate explicit invulnerability query is a query, not an additional hit attempt.
The native regression test native_hit_attempt_tests counts actual listener notifications on
accepted and refused player and mob routes, equipment continuations, effects, explosions, and
small-fireball, wind-charge and shulker-bullet projectile entries. Unchanged listeners preserve the expected
health, refused fireballs restore prior fire, and only successful shulker hits grant levitation.
A decision-changing case checks that the modified answer survives continuations and a new attempt
asks again. The test uses prepared resources.
The additional native remaining_hit_callers_observe_refusals_and_zero_damage regression enters
actual skull, rocket, command and zero-damage projectile routes. It distinguishes attached from
splash spectator targets and checks resident dead mobs and dead or unloaded LAN guests. These
caller checks supplement the shared damage-pipeline tests rather than synthesizing hook calls.
Saved-world mod mismatches
Hooks::mod_mismatch reports changed and removed saved mods whenever the world's
saved list exists, including empty and unchanged lists; newly added mods and
minecraft are excluded. Both logical sides can open worlds, and singleplayer
opens once on the integrated server.
Unlike NeoForge's raw-read timing, this runs at the earliest available Host before world-state restoration, after preparation, because it only reports and logs and its resolution output does not depend on the earlier moment.
Versions use the loader's semantic version comparison, padding omitted minor and
patch components with zero; invalid or empty versions compare as exact text.
Mark a migration with event.resolved.push("my_mod".to_owned().into()).
The first listener to mark an id owns its resolver attribution; later listeners
cannot erase or replace that marker. Markers for other ids are allowed.
Differences are logged sorted by mod id, resolved at debug with the resolver and
self/other distinction, unresolved at warn. No dialog or cancellation is added.
The current mod list is written on the next save, not when this event fires.
Villager secondary work blocks
Add blocks to a vanilla profession's secondary work sites with the block tag
horizon:villager_secondary_poi/<profession>. For example,
data/horizon/tags/block/villager_secondary_poi/farmer.json:
{"values": ["minecraft:soul_sand"]}
Entries add to the profession's defaults. Farmers retain farmland, including
blocks using the farmland template. Other vanilla professions can use their own
names, such as fisherman. Mod professions await profession registration.
Tags use the normal world-open and /reload tag resolution. Successful reloads
replace membership; failed reloads retain the previous world data. Removing all
entries restores vanilla behavior.
The server sensor keeps its existing cadence and inclusive scan of four blocks in x and z and two in y. Professions with empty secondary sets skip the scan. Results populate secondary job-site brain memory. The shared work activity can stroll toward a randomly selected secondary site while near its primary job site. Farming still requires a farmer and retains its existing crop and farmland checks.
Making a content mod
Trait mods declare content through BlocksEvent and ItemsEvent, using API-owned
mod_api::Block and mod_api::Item records. Start with Block::new(id, template) or
Item::new(id, template), passing identifier strings, then set the overrides. The drafts are unvalidated; admission reports malformed values with the content loader's refusal messages.
These are load-time declaration drafts, not live registry entries. The game admits them
with the content-file checks, atomically for each namespace, before resources load.
A block's item defaults to false in code. Each mod receives its own declaration
batch. New identifiers from code—including block entity types and other registrar
declarations—may use any namespace owned by that mod's manifest. Bare names use
the mod ID; an explicit owned namespace stays unchanged. An unowned namespace
refuses registration with a diagnostic naming both the mod and namespace. This
is identical for native and wasm mods. Attaching a Part to an existing vanilla or
foreign block or item is a reference, not a new declaration, and remains allowed.
Code declarations carry component patches as ordered Vec<(String, Option<Tag>)>:
Some(tag) sets the named component and None removes it (without a ! prefix).
The game converts this opaque NBT data at load; file declarations below still use JSON.
No declaration conversion runs per tick.
Choosing what remains after removal
Implement BlockPart::remove_block for a block that leaves a support or another
state behind when removed. This server-only replacement receives the dimension,
position, removed state and moved_by_piston. Answer Some(state) to choose the
replacement, or None to keep vanilla's fluid block (air when dry). Only the
removed block's own Part is called, not the replacement block's Part.
The chosen state goes through the normal setBlock effects with flags 3, plus
64 for piston movement. An unknown or unregistered state is refused with a
reason and the vanilla replacement is written. This covers Level.removeBlock,
not every direct setBlock or the separate destroyBlock path. The level view
is read-only while answering, on both native and wasm.
Making a content mod
# A prebuilt binary for Linux x86_64/aarch64, macOS x86_64/arm64 or Windows x86_64 (#2120):
cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod
# Or build it from source, without cargo-binstall:
cargo install --git https://github.com/horizon-rust/horizon cargo-horizon-mod
cargo horizon-mod new my-mod # a content mod: manifest, one block with its model, loot and recipe
cd my-mod
cargo horizon-mod run # build my-mod.hmod, then start the game with it turned on
cargo horizon-mod build # only build: target/horizon-mod/my_mod.hmod
cargo horizon-mod check target/horizon-mod/my_mod.hmod # the registry's checks; --json for JSON
new <path> [--name "Name"]writes the content template.--template nativewrites a flat trait mod instead:horizon-mod.tomlwith[native]pinned to the newest prebuilt game's commit (or--horizon <commit>'s) so the launcher downloads the game instead of building it, rootCargo.tomlandsrc/lib.rs, and emptyassets/anddata/directories.updaterepins it the same way. Its rootimpl Hooksuses#[mod_api::horizon_mod]. Compile it withhorizon-launcher native-build <path> --trust-native-code(trait mods). Any other--templateis refused.buildpacks the checked project intotarget/horizon-mod/<id>.hmod:horizon-mod.toml,assets/,data/, optionalicon.pngand, for code, the root Cargo project. It validates content with the same checks as the game; it does not compile code. Code requires bothCargo.tomland[native]; inconsistent declarations and a manifest without capabilityregistriesare refused.runbuilds, then startshorizon-client(onPATH, or--game PATH, orHORIZON_CLIENT) with--dev-mod target/horizon-mod/<id>.hmod, always on. Arguments after--go to the game. The game reads content once at start, so rebuild and run again after an edit.check <file>runs what the registry runs: the file's size and SHA-256, its manifest and content, and a signature with--key <public key> --signature <file>.minisig. It reads.hmod,.hpack, resource pack.zipand.hshaderfiles.registry build|review|check|list|release|pageis the registry repository's CI (the registry index).use-jar <path>records an original Java jar as an external asset/data source, without copying it into the mod (Java jars as external assets).
The manifest
id = "my_mod" # [a-z0-9_]{1,64}: settings key, log name
name = "My mod"
version = "0.1.0"
description = "..."
authors = ["..."]
license = "MIT" # optional: the Mods screen shows it
license_url = "https://..." # optional: makes the license a link
credits = "..." # optional: thanks, shown under the authors
homepage = "https://..." # optional: the Mods screen's Homepage button
issues = "https://..." # optional: the Mods screen's Issues button
api = "0.2" # content mods
side = "both"
capabilities = ["registries"]
namespaces = [] # optional: more namespaces the mod's files use (below)
The optional fields fill the game's Mods screen, which lists every mod with its icon, name, version, id, license,
authors, credits and description, as NeoForge's mod list does. A link must be http or https and at most 2048
characters; the others are plain text. The screen has no switch: a mod runs or not by what the launcher and
horizon-mods.txt say.
Unknown keys are refused, so a typo is an error, not a silently ignored permission. The shared manifest
uses exactly api = "0.2" and capabilities = ["registries"], with side = "client" (the default) or "both".
Code mods add [native] for exact build pins and optional raw injections (Injections); their id and version still come only
from this manifest, not Cargo metadata (Native mods). Both content and code mods support [dependencies]: use base = "^1.2" for a required dependency or
extra = { version = "^2", optional = true } for an optional one. Missing required dependencies, incompatible installed versions and cycles refuse startup. Content layers load after their dependencies; an absent optional dependency is ignored. Both kinds may include a fixed-name icon.png at the top level, with no
manifest key: a valid square PNG, at most 512 × 512 pixels and 256 KiB encoded (Mod files).
Executable mod.wasm files are unsupported: a folder or archive containing one is refused, including when
installed through a modpack. Code is never silently skipped.
Java jars as external assets
From a content or native mod's project folder:
cargo horizon-mod use-jar /path/to/original-mod.jar
The command reads NeoForge or Fabric metadata and appends a [[jar]] table to horizon-mod.toml with the original
id, version and the exact file's sha512 (128 lowercase hexadecimal characters). It does not infer identity
from the filename, rename the Java mod to the Horizon mod's namespace, or run Java code. An unknown jar, invalid
identity, unresolved version or file larger than the shared 512 MiB hash limit is refused without changing the manifest.
The original manifest text, comments and existing list order are preserved. Listing the same hash and identity again
leaves the file byte-for-byte unchanged; a hash already listed with another ID or version is an error to correct,
not another entry. Use [[jar]] tables rather than an inline jar = [...] array when adding further jars with the command.
The list is ordered: new references are appended, not sorted.
Jar bytes stay external. The launcher finds the exact hash locally or asks before downloading that exact file from
Modrinth. Its assets/ and data/ are passive pack layers below the Horizon mod's own files; a player-local runtime
cache is not part of the mod package. Do not copy a jar or its extracted files into the mod's source or .hmod.
A jar reference supplies assets and data, not the original Java behavior: port that behavior through the mod API.
Content mods (API 0.2)
A content mod has capability registries and carries assets/ and data/ in the .hmod. It adds blocks and items
by declaring them in data/<id>/horizon/block/*.json and data/<id>/horizon/item/*.json, each copying a vanilla
template (modpacks.md), and creative tabs, mob
effects, entity types and menu types (each below) in creative_tab/, mob_effect/, entity_type/ and menu/ beside
them. fluid/ gives a fluid a client look (sprite, an atlas sprite id, and tint, 0xAARRGGBB, both optional), as
NeoForge's IClientFluidTypeExtensions; the fluid needs no block, and a recipe page draws its well from it. Its world
object makes it exist in the world: {"world": {}} adds the block <id>:<path>, a copy of minecraft:water that flows
with vanilla's fluid simulation, and the bucket item <id>:<path>_bucket, which needs its model as any item does. world
takes tick_delay (1 or more, default 5), drop_off (1 to 7, default 1), slope_find_distance (1 to 4, default 4),
source_conversion (default false) and bucket (default true). The fluid is in no vanilla fluid tag, so entities don't
swim in it or get pushed by it. In the world it draws its sprite, a block atlas texture such as mymod:block/oil, times
its tint; with neither it looks like water, and a tint alone colours water's textures. A client mod's
register_fluid_models hook collects mod_api::FluidModel values with separate flowing and overlay sprites.
It is collected again before each resource bake, so a resource reload can change model identifiers as well as textures. Far terrain (LOD) doesn't draw mod fluids yet. LAN and dedicated server players play with them when they run the same mods: a client whose mods add different
content is refused when it joins, with the reason.
Every Horizon server checks loaded mods in the configuration phase (horizon_protocol::mod_list, channel
horizon:mod_list), including when its own list is empty. Only the ids and versions of side = "both" mods cross
this channel. Missing mods, extra both-side mods and version differences refuse the join, naming every id and
version in a translated, wrapped disconnect. Client-only mods stay private and never block a join. A manifest
with no side line is client, so a mod meant for both halves must say side = "both". A vanilla or other
non-Horizon client is refused when the server needs both-side mods, with their names and versions and an English
fallback for clients without Horizon translations. The content registry identity check remains separate; the
mod list does not hash package bytes. Native build inventory validation is a separate server-export concern.
This is the fork's compatibility policy, not NeoForge channel negotiation. Diverges from upstream: the wire contains ids and versions only, with no side/hash fields or legacy format fallback; the old six-name display cap is removed. Upstream syncs must keep this contract and the configuration-phase check on both endpoints.
A payload that breaks the format (an oversized list, a duplicate id, trailing bytes) is refused the same way a mismatch is: the join ends in a translated disconnect naming the decode error, never in play, and never as a silently empty list.
Horizon peers identify themselves before sharing mod lists. After receiving the server's horizon brand, the
client announces its own brand and list before answering known packs; that reply orders the compatibility
check without an extra ping. The server sends its list only to an identified Horizon client, including when
either list is empty. Vanilla clients receive no mod-list payload, and clients do not disclose their mod
identities to servers that have not identified themselves as Horizon. Nothing here repeats per tick.
A block names its template and what differs from it:
{ "template": "minecraft:iron_block",
"properties": { "hardness": 4.0, "light": 3, "sounds": "minecraft:amethyst_block" },
"item": true }
A fence gate needs no kind of its own: a block whose template is a vanilla minecraft:*_fence_gate is a
FenceGateBlock, with the template wood's open and close sounds. "open_sound" and "close_sound" (sound event ids,
such as "minecraft:block.chain.break" or a sound of the mod's own sounds.json) replace them, as a gate built with its
own WoodType has, and they are what opening by hand, a redstone signal and a wind-charge or other explosion toggle all
play. Either key needs a *_fence_gate template and a namespace:path id, or the load fails naming the file; leaving
both out keeps the wood's sounds.
A block gets a block item of its own id by default. "item": false gives it none: pick-block takes nothing and
Block.asItem is air (what a block drops comes from its loot table). "block_item": "<item id>" (with "item": false) makes the block
that other item instead, an item of the mod or a vanilla one, such as "minecraft:diamond": pick-block and
Block.asItem both read it, and the load fails naming the block when no such item exists. "none" is not an item id;
write "item": false. A sign's wall form takes its sign's item through wall_block, not block_item.
Pick-block takes the block's item by default. "pick_item": "<item id>" names a different one, with any item and
block_item: vanilla's getCloneItemStack, which lets a mature crop pick its seed while Block.asItem stays what it
is (air for "item": false). It is an item of the mod or a vanilla one, and the load fails naming the block when no
such item exists. A sign's wall form picks its sign's item through wall_block, so it declares no pick_item.
push_reaction sets vanilla's BlockBehaviour.Properties.pushReaction to "PUSH_PULL", "PUSH", "POPPED", "IMMOVEABLE", or "IGNORE_ENTITY".
random_ticks makes a block tick randomly whatever its template does, as vanilla's
isRandomlyTicking(state) does for its own blocks, so a crop, sapling or soil on a template that never
ticks still grows: "random_ticks": true for every state (or false, to stop a template that ticks), or
{ "property": "grown", "values": ["false"] } for the states whose own state property (declared in
state_properties) is one of the values. The block's native native_random_tick hook then fires as it does
for a vanilla block that ticks, and its sections count it.
render_shape says how a block is drawn whatever its template does, as vanilla's getRenderShape:
"MODEL" puts its block model in the chunk mesh, "ENTITYBLOCK_ANIMATED" leaves the model out for a block whose
moving parts its block entity draws every frame (a turning shaft, a machine's arm), and "INVISIBLE" draws nothing.
Its outline, collision, light and occlusion stay as declared: on a template that occludes (stone), the faces it
covers stay hidden unless it also sets occludes.
occludes: false makes a block occlude nothing whatever its template does, as vanilla's noOcclusion(): the faces
of the blocks beside it are drawn, so a thin panel or a block drawn by its block entity on a full-cube template
doesn't leave a hole in the floor under it. It is no longer a solid render either, so light passes it as it passes
leaves: dampened by 1, or not at all if skylight passes down; special template light rules remain inherited.
Unset or true preserves the template's ability to occlude, using the declared outline when one is supplied.
destroy_overlay: false hides the breaking crack (destroy_stage_0 to 9) on the block, for one broken by use
(a feast or a cake taken apart bite by bite). It is read once at load into the block's facts, and only the overlay
is skipped: the mining progress, the server's progress and the destroy-progress packets other players see stay
vanilla's. Unset or true, the crack is drawn. It stands where a mod once had a mixin on
LevelRenderer.renderBlockDestroyAnimation, which has no NeoForge event.
A block copies its template's states. It can also declare state properties of
its own, which its template does not have — how a pipe says which sides it
connects to, or a machine whether it is running. Its states then become its
template's states times the combinations of the ones it declares, laid out as
vanilla lays out a block's states (StateDefinition): every property, the
template's and the declared ones, sorted by name with the first name varying
slowest, and each declared property's default value first. The order is fixed,
so ids do not move between loads, and a declared name may sort before one of
the template's (bites on a carved pumpkin, whose facing comes after it):
{ "template": "minecraft:stone",
"state_properties": {
"facing": { "type": "enum", "values": ["north", "south", "east", "west"] },
"lit": { "type": "boolean" } },
"default_state": { "lit": "true" } }
A boolean is false and true, as vanilla spells them; an enum is a list
of values, and a name or value is lower case letters, digits and underscores.
A declared name may not be one the template already has, and default_state
takes one value per declared property too, so a block's default is exactly one
state of its own. Every combination has to fit in the states a world holds
(2^20), so a declaration that would not is refused when the mods load.
A block with template minecraft:farmland may set "base_block": "namespace:block" to choose the block it becomes when it dries out, is trampled, or has a solid block placed above it. The ID must name a registered block; without the key, the template's dirt base is used.
A block whose template is one of vanilla's signs is a sign, whatever its name: it has vanilla's sign block entity, so the text, the editor, dye, glow ink, wax and saving all work as they do on an oak sign, and the client draws its text. A wooden sign is two blocks and a hanging sign two more, one form per template, and the one with the item names its wall form:
{ "template": "minecraft:oak_sign", "wood_type": "ruby:elm", "wall_block": "ruby:elm_wall_sign" }
{ "template": "minecraft:oak_wall_sign", "wood_type": "ruby:elm", "item": false }
The same goes for minecraft:oak_hanging_sign and minecraft:oak_wall_hanging_sign. wall_block is what the
sign's item places on a wall, a block of the same mod, declared in its files when the sign is; without it the
item places the template's wall sign. The wall form has no item of its own, and its sign's item is what picking
it gives. wood_type names the editor's background:
ruby:elm is assets/ruby/textures/gui/signs/elm.png (24x26) and assets/ruby/textures/gui/hanging_signs/elm.png
(16x16), and a bare name is vanilla's, such as cherry; without it the template's wood is used. The board is
the block's model: minecraft:block/template_sign_rot_0 to _rot_3 and template_wall_sign, and for hanging
signs template_hanging_sign_rot_0 to _rot_3, template_attached_hanging_sign_rot_0 to _rot_3 and
template_wall_hanging_sign, with all and particle textures. List your blocks in vanilla's tags as well:
minecraft:standing_signs, minecraft:wall_signs, minecraft:ceiling_hanging_signs and
minecraft:wall_hanging_signs, which vanilla's other sign tags and minecraft:mineable/axe include. Give the
wall form a loot table of its own, or name it <x>_wall_sign so that it drops what <x>_sign's table does.
sign_text_color (a DyeColor name such as "white" or "light_blue", black when unset) and sign_text_glow
(true or false) are the text a new sign starts with, as SignBlockEntity.createDefaultSignText gives it, for a
dark board whose black text would not read. They apply to the front and back text of a sign when it is placed, and
dye and glow ink work on it as usual; a saved sign loads as vanilla's does. A sign and its wall_block declare the
same values, so a sign does not change colour when it is hung on a wall.
A block can override its targeting outline with shapes. The declared outline also controls render
solidity, face occlusion and default lighting: an occluding full cube is solid and blocks light; a partial
shape uses its covered faces to stop light directionally. An empty or partial outline is not a solid
render. Fluid-bearing states, including intrinsic water in bubble-column and kelp templates, retain their
fluid attenuation and downward-skylight behavior when the declared geometry is unchanged. Special
skylight and light-dampening rules remain inherited from the template: fluid-dependent skylight rules
(such as vegetation) use the resolved state's fluid, including declared waterlogging, while glass keeps
its unconditional skylight rule and leaves keep their dampening. For example, a partial poppy-template
outline passes skylight without attenuation when dry, but attenuates it by 1 when waterlogged.
occludes: false still disables face occlusion.
Movement collision is independently selectable with collision_shapes; it does not change the outline,
render solidity, occlusion or lighting. Both take the same table format. Boxes are
[minX,minY,minZ,maxX,maxY,maxZ] in block units (one block is 1), at most 16 per shape.
Coordinates must be finite and each minimum must not exceed its maximum. [] is an empty shape.
{
"template": "minecraft:stone",
"state_properties": {
"facing": { "type": "enum", "values": ["north", "south"] },
"count": { "type": "enum", "values": ["1", "2"] }
},
"shapes": {
"properties": ["facing", "count"],
"values": { "north,2": [[0,0,0,1,0.5,1]] },
"default": [[0,0,0,1,1,1]]
},
"collision_shapes": { "default": [] }
}
properties names an ordered tuple of template and/or declared state properties; each values key
lists their values in that order, separated by commas without spaces. A nonmatching tuple uses
default. Without default, every tuple must be covered. A default-only table gives every state the
same shape. A single-property table uses "properties":["axis"]. Unknown properties, unreachable
value tuples, malformed boxes and duplicate property names reject the mod with a load error rather
than silently ignoring a typo. Omitted shapes preserves the template outline (or the connecting
block's thickness outline). Omitted collision_shapes preserves the existing behavior: a declared
outline also becomes collision, otherwise the template collision is retained. An explicit collision
table can be used without an outline override, and overrides thickness collision too.
Entity-context shape declarations
Native declarations use the same fields on mod_api::Block, with API-owned
ShapeEntityKind::{Item, Player, Living, Other} keys and BlockShapes values.
interaction_shapes is an optional BlockShapes. The game validates these drafts
at content admission, just like their JSON counterparts.
outline_context_shapes and collision_context_shapes independently override
shapes and collision_shapes for a captured entity class. Their keys are
item, player, living (a non-player living entity) and other; each value
is a shape table with the same properties, tuples, defaults and box validation
as shapes. There is no inheritance between classes or channels. An omitted
class keeps that channel's ordinary shape. Empty and position-only queries
always keep the ordinary shape; a selector is not inferred from a runtime entity ID.
{
"template": "minecraft:stone",
"shapes": { "default": [[0, 0, 0, 1, 0.125, 1]] },
"outline_context_shapes": {
"player": { "default": [[0, 0.125, 0, 1, 1, 1]] }
},
"collision_context_shapes": {
"item": { "default": [[0, 0, 0, 1, 0.3125, 1]] }
},
"interaction_shapes": { "default": [[0, 0, 0, 1, 1, 1]] }
}
interaction_shapes uses the same state-table format. It supplies
BlockGetter.clipWithInteractionOverride: an earlier interaction-shape entry
changes the face of an existing outline hit while keeping its point. It cannot
create a hit through an empty outline or extend the ray's reach.
Tables compile once per resource snapshot. Collision scan emptiness and
out-of-cell metadata account for every collision variant. Dropped-item movement
and overlap checks carry item, player movement and placement carry player,
and player selection uses its own outline channel. Callers without a captured
entity class retain ordinary shapes. These declarations select state/class-only
geometry, not arbitrary world-dependent block behavior or native callbacks.
An item copies its template's data components, with max_stack_size and
rarity changed, and any other 26.3 component set or removed in
components, written as a data component patch is: minecraft:<type> sets
one, !minecraft:<type> with {} removes it. That is how a modded sword
gets its own damage and durability, or a tool its mining speed:
{ "template": "minecraft:iron_sword",
"components": {
"minecraft:max_damage": 900,
"minecraft:attribute_modifiers": [{ "type": "minecraft:attack_damage",
"id": "minecraft:base_attack_damage", "amount": 7.0,
"operation": "add_value", "slot": "mainhand" }],
"!minecraft:repairable": {} } }
A held item's third-person arm pose follows its use animation, as vanilla's AvatarRenderer.getArmPose reads it: the
minecraft:consumable component's animation (eat, drink, block, bow, trident, crossbow, spyglass,
toot_horn, brush, bundle, spear or none), else block for an item with minecraft:blocks_attacks. A content
item with "minecraft:consumable": { "animation": "toot_horn", "consume_seconds": 7 } raises its arm like a goat horn
while it is used, for players and in other players' views.
arm_pose names the pose a humanoid's arm takes holding the item, in place of what vanilla gives it, for as long as
the item is in that hand (a used item's animation included): one of vanilla's HumanoidModel.ArmPose names, empty,
item, block, bow_and_arrow, crossbow_hold, crossbow_charge, throw_trident, toot_horn, spyglass, spear
or brush. A name that is no pose fails the mod's load, and an item without the key poses as before. It applies to
player models, in the first person's own third-person view and in other players'; spear poses an arm only for an item
with a kinetic weapon. A code mod that must decide by stack, hand or entity answers the get_arm_pose hook instead,
which wins over the declared key.
Code mods can also register a namespaced custom pose once in Hooks::register through
Registrar::register_arm_pose("mymod:flip", uses_both_arms, affects_offhand). Keep its
CustomArmPose token and return ArmPose::Custom(token) from get_arm_pose. The two
flags independently control the humanoid's two-arm ordering and whether the other
hand's pose gives way. The token carries these flags as Wire data, so the pose's
flag getters do not require a registration table inside a wasm guest. Duplicate, malformed and foreign-namespace ids refuse registration.
In arm_pose_transform, match event.pose to that token and set event.angles.right
or event.angles.left to absolute [pitch, yaw, roll] in radians. event.right_arm
identifies the physical arm; event.humanoid supplies both stacks, the use state,
and the poses selected after get_arm_pose answers. Transformers follow vanilla's
physical-arm application order and do not run for a suppressed pose.
Each non-finite angle leaves the base model component unchanged. The transform runs
only for selected registered poses, on local players, remote players and humanoid mobs;
swing and mob-specific animations still run afterward and can change those angles.
Native and wasm mods use the same registration, token and Wire angle value.
{ "template": "minecraft:stick", "arm_pose": "toot_horn" }
An item can place a block by naming it with block, whether it is a vanilla block or a declared mod block:
{ "template": "minecraft:wheat_seeds", "block": "ruby:crop" }
This uses the block's placement state and survival rules and consumes the item outside creative mode. Add seeds to
minecraft:villager_plantable_seeds for farmers to plant them. The mapping does not change what the block drops or
what Pick Block gives.
An item whose template is minecraft:fishing_rod (and no block) is a fishing rod, as a NeoForge mod's
new FishingRodItem(...) is: it casts, floats, bites, reels in and wears as vanilla's rod, its item model's
minecraft:fishing_rod/cast is true while its bobber is out, and native_player_fish hears it. List it in
#minecraft:enchantable/fishing for Lure and Luck of the Sea, as vanilla's rod is. Other behaviour that vanilla ties
to an item's class, such as a bow's draw or a shield's block, still belongs to vanilla's item alone; components
(minecraft:tool, minecraft:consumable, minecraft:equippable and the others) carry over for every template.
{ "template": "minecraft:fishing_rod", "components": { "minecraft:max_damage": 128 } }
Native mods can register component types before content loads. A content item
can set a registered type with a JSON value, such as "sample:charge": {"charge": 42}.
JSON values become NBT values. Values must fit the network NBT limits and
round-trip through saved NBT; negative zero is not supported. A key such as
"!sample:charge": {} removes the template's default. Unknown types fail when Horizon builds the content root;
cargo horizon-mod check cannot check native registrations.
crafting_remainder names what the item leaves in the crafting grid when a
recipe uses it, as a milk bucket leaves a bucket (vanilla's
Item.Properties.craftRemainder, not a data component): a vanilla item or
one of the mod's own, refused at load otherwise. It holds on the crafting
table, the inventory grid, the crafter and as furnace fuel (the last of the stack leaves it in
the fuel slot, a stack with more left drops it), not yet in a brewing stand. What the item leaves once eaten
or drunk is the minecraft:use_remainder component instead:
{ "template": "minecraft:mushroom_stew", "crafting_remainder": "minecraft:bowl" }
A block's item takes the same components, over the components of its
template block's item; a block with "item": false can't have them. Block files
have no max_stack_size or rarity keys, so those two go in components here
(an integer 1 to 99, and one of common, uncommon, rare, epic):
{ "template": "minecraft:iron_block",
"components": {
"minecraft:max_stack_size": 16,
"minecraft:damage_resistant": { "types": "#minecraft:is_fire" } } }
A block that joins its neighbours in six directions (a cable, a pipe) copies
minecraft:chorus_plant, vanilla's PipeBlock, and names the block tag it
connects to besides itself:
{ "template": "minecraft:chorus_plant", "connects_to": "#mymod:cable_connects" }
It keeps the chorus plant's six sides but none of its rules: it needs no
support, and each side is joined when the block there is the same block or in
the tag, on placement and whenever that neighbour changes. Its shapes are the
chorus plant's (10 pixels across) unless it sets "thickness", 2 to 14
pixels: its outline and collision are then built as PipeBlock builds its
own, a core and an arm to each joined side.
A mod's creative inventory tabs are data/<id>/horizon/creative_tab/*.json:
a title, the item on the tab's button, and its items in order (items the game
doesn't have are left out):
{ "title": "Ruby", "icon": "ruby:ruby", "items": ["ruby:ruby", "ruby:ruby_block"] }
They come after vanilla's tabs, ten to a page; the
buttons above the inventory turn the pages. The title is a lang key or English
text: "title": "itemGroup.ruby" uses that key from the mod's lang files, and a
lang file's itemGroup.<mod id>.<file name> (creative_tab/gems.json →
itemGroup.ruby.gems) translates any title. A mod's items join the search tab
under their names.
A file only makes new tabs. To put items into a tab the mod doesn't own, such as
vanilla's minecraft:ingredients or another mod's, a native mod listens to the
client hook build_creative_mode_tab_contents (NeoForge's
BuildCreativeModeTabContentsEvent) and pushes its stacks for that tab.
A component type no vanilla item has is refused, so a misspelt one fails the load instead of doing nothing. Behaviour the game ties to a vanilla item's id doesn't come along yet: a copy of the trident doesn't throw (#3305).
A mod effect is declared in data/<id>/horizon/mob_effect/<name>.json with what vanilla's MobEffect takes: its
category (beneficial, harmful or neutral) and its RGB colour (0 to 0xFFFFFF), which its particles take:
{ "category": "harmful", "color": 9154528 }
It is the effect <id>:<name>: /effect give @s ruby:glow 30 gives it, saves and mobs keep it, and it reaches LAN and
dedicated server players who run the same mods (its network id follows vanilla's). Its name is the translation
effect.<id>.<name> from the mod's assets/<id>/lang/, its icon assets/<id>/textures/mob_effect/<name>.png (18x18,
as vanilla's; without one it draws as the missing texture). It does nothing on its own yet: no tick behaviour and no
attribute modifiers. A world opened without the mod drops the effect from its players and mobs. Clients join a server
only with the same mods' effects, as with its blocks.
Code mods read a registered type's classification with effect_key.category(host),
which returns EffectCategory::Beneficial, Harmful or Neutral. For an active
instance, use active_effect.id.category(host). The read works on both sides and
returns None before the effect registry is complete; it never guesses a category.
A mod entity type is declared in data/<id>/horizon/entity_type/<name>.json. It copies a vanilla type:
minecraft:snowball, a thrown item projectile, or a living mob (below). A thrown type's item is one of the mod's own
items (one type per item), and damage (0 by default) is what a hit deals to anything it hits, as thrown damage from
the thrower (the snowball's 3 to blazes is not copied):
{ "template": "minecraft:snowball", "item": "ruby:ember", "damage": 2.0 }
It is the entity type <id>:<name>, with its network id after vanilla's. Using its item throws it as a snowball is
thrown: same speed, spread, sound and cooldown, and one item used outside creative. It flies as a snowball, breaks on
what it hits with its item's crumbs, and is drawn as its item's model (ThrownItemRenderer). Its name is the translation
entity.<id>.<name>. It works in single player and on LAN with the same mods. Dedicated servers share that code,
untested so far. Clients join a server only with the same mods' entity types, as with its blocks. It is saved with its
chunk, /summon <id>:<name> makes one, and it has its template's row in entity-types.json, with template naming
it. A world opened without the
mod keeps the saved ones as they are, frozen in place. Not yet: a dispenser doesn't throw it, and a vanilla client on a
server with it may be disconnected when one spawns.
A code mod can launch a thrown type with
host.spawn_projectile_from_rotation(owner, &entity_type, &stack, offset, power, inaccuracy).
This is the owner-based Projectile.spawnProjectileFromRotation operation, not a summon:
ownership is set before shooting and the existing launch hook runs after insertion.
It supports registered mod thrown types and vanilla snowballs, eggs, pearls, potions,
experience bottles and wind charges. Spread uses the projectile's own random in x/y/z
order, and known owner movement is inherited (vertical movement only when airborne).
It does not consume an item, award use statistics or apply cooldowns; invalid parameters
or unsupported types return None without spawning or drawing random values.
The owner must be in the current level; a guest in another dimension is refused.
The carried item count is one; the supplied stack is unchanged. Once the projectile is
in the level, the stack's enchantments run their projectile_spawned effects on it, as
vanilla's Projectile.applyOnProjectileSpawned does after a throw: in the stack's order,
each effect's requirements tested with the level random just before it applies, and its
own draws from the projectile's random. ignite, apply_impulse, play_sound and an
all_of of them apply; apply_mob_effect and apply_exhaustion do nothing to a
projectile, as in vanilla. A stack with any other projectile_spawned effect (blocks,
damage, explosions, particles, summons, functions, item damage) is refused until the
engine applies it too (#2670), so this operation never silently discards one.
Owner launch also refuses unsupported requirement contexts (including entity flags),
and invalid clamped level-value ranges before insertion. Supported requirements
are entity type predicates, random chance, and their boolean combinations.
A living type copies a mob, minecraft:zombie or minecraft:villager, the way NeoForge's
EntityType.Builder.of(Zombie::new, ...) makes a type of a vanilla class. It has no item or damage. attributes
sets default attribute values that differ from its template's (EntityAttributeCreationEvent), by attribute id, and
only for attributes the template has; one it lacks stops the content root's build:
{ "template": "minecraft:zombie", "attributes": { "minecraft:max_health": 40.0, "minecraft:movement_speed": 0.3 } }
A living type can set sounds.ambient, sounds.hurt, sounds.death and sounds.step:
{
"template": "minecraft:wandering_trader",
"sounds": {
"ambient": "bird:entity.vulture.ambient",
"hurt": "bird:hurt",
"death": "bird:death",
"step": "none"
}
}
Each omitted slot inherits the template's existing selection, including runtime choices
such as a villager's trading voice and the supporting block's footstep sound. An explicit
full namespace:path id selects that sound event from the resource packs; "none" mutes
only that slot. Omission and "none" are different: a villager with no step override
still plays minecraft:block.stone.step on stone. Invalid ids stop content admission,
with the entity and sound slot in the error. These four slots cover ordinary ambient,
hurt, death and footstep playback; additional events such as trading interactions or
projectile attacks keep their existing sounds. Native and wasm packages use this same
content definition and resource table.
A code mod can also register an attribute type of its own with Registrar::declare_attribute(path, default, min, max, syncable, sentiment) (vanilla's RangedAttribute in the attribute registry). Only both-side
packages may declare attributes; client-only and server-only packages refuse with WrongSide so extra
client mods cannot shift packet IDs. mod_api::Sentiment is Positive, Negative, or Neutral.
The id lands in the
mod's namespace, the bounds and default follow RangedAttribute's own checks, syncable decides whether the
value syncs to clients, and sentiment (POSITIVE by default) colors the attribute's tooltip lines. The declared
attributes append after the vanilla ones in the attribute catalog — sorted by declaring mod id, then attribute
id, so the network ids are the same on every side running the same mods — before entity attribute rows resolve,
so a mod's entity set may name its own new attribute. Values sanitize through the definition (NaN maps to the
minimum, everything else clamps), and saves, item modifiers and the sync set carry the attribute by its string
id.
A code mod can declare a datapack registry of its own with Registrar::declare_datapack_registry(path, sync, fields) (NeoForge's DataPackRegistryEvent.NewRegistry; #811).
path lands in the mod's namespace (spell of example is example:spell), and fields is a flat record
schema: Bool, Int { min, max }, Float { min, max }, Text { max_len }, Identifier or Reference { registry },
each required or given a default. Entry addon:fire is data/addon/example/spell/fire.json
(DatapackRegistryDeclaration::entry_file), the registry's namespace being a folder as NeoForge's modded
registries have it. The game decodes entries against the schema without running mod code, so native and wasm
mods get the same bounds: an undeclared key, a missing required key, a wrong type or an out-of-range value fails
the entry, and a default never stands in for invalid data. The engine adds no entry-size or field-count limit
of its own. Only both-side packages may declare registries. This slice declares and validates; loading the
entries from packs, the duplicate check across mods and typed reads follow in the next parts of #811.
A code mod can also use Registrar::declare_entity_attributes(path, template, attributes) during registration.
That declaration starts from the vanilla template's attribute row, replacing the content row's overrides.
Declared bases remain available to base-value reads, but computed values and spawn fields clamp them to each
attribute's vanilla range (for example, minecraft:max_health is clamped to 1–1024).
To give an existing type, vanilla or a mod's, an attribute it lacks, a code mod calls
Registrar::modify_entity_attributes(entity, attributes) (NeoForge's EntityAttributeModificationEvent) during
registration. Each attribute takes its base, or with None the attribute's own default. It changes the type's
default after every declared set, so entities created after load have it; a later addition of the same attribute
wins. A mod's type needs its declared set first: one that only a content file defines has no attribute row to add
to. An attribute the game does not know, or a type without an attribute row, stops the resource load:
registrar.modify_entity_attributes(
"minecraft:zombie",
vec![("minecraft:luck".to_string(), Some(2.5))],
)?;
It is a mob of its template's class under its own id, <id>:<name>: the template's size, model, texture, sounds and
behaviour, with its own attributes. It saves what its template's class saves under its own id (a zombie's IsBaby,
CanBreakDoors and conversion times) and loads back as itself. Its entity data is its template's class's. Its loot
table is its own, <id>:entities/<name> (vanilla's EntityType.getDefaultLootTable). It is not in vanilla's entity type
tags (#minecraft:undead, #minecraft:zombies...) unless the mod adds it. Its row in entity-types.json is its
template's, with template added and its attributes in place of the template's. Unit tests check all this; it has
not yet been tried in a running game. Not yet: its own model or texture (a native mod draws one with
mob_submit), and its own goals.
Menu types
A mod menu type (vanilla's MenuType) is declared in data/<id>/horizon/menu/<name>.json: its container's slot count
(1 to 256), the data slots its menu syncs (ContainerData, 0 to 256, 0 by default), and the slots players only take
from (results, as a furnace's result slot):
{ "slots": 9, "data_slots": 2, "results": [7, 8] }
It is the menu type <id>:<name>, with its network id after vanilla's. A mod block's container opens it when its
menu names it with as many slots (native mods, native_block_entity_container). The menu holds
its slots, then the player's 36. A shift-click from the inventory goes to the first slots with room, never a result
slot, and one on the container goes to the inventory, as a chest's does. results fence players only: hoppers follow the
container's faces and inputs, so leave result slots out of inputs. The data slots are the block entity's data
(16-bit on the network, as vanilla's), all sent when it opens, then each change. A Horizon client shows it through the
mod's own screen (native_screen); without one it closes again. Clients join a server only with the same mods' menu
types, as with its blocks, and a client that isn't Horizon's is never sent one: the block opens nothing for it. Its
clicks run on the server's vanilla menu engine (menu::Kind::Mod), shift-clicks routed as ChestMenu's, and a native
mod can decide what a slot takes in code with native_menu_slot_rules (hook notes).
Not yet: declared per-slot rules beyond results, hooks for shift-click routing and stillValid, opening with extra
data, and client prediction of its clicks.
Namespaces and limits
The fence: files go in the mod's own namespaces (assets/<id>/,
data/<id>/, and those its manifest lists), plus additions to vanilla tags
(data/minecraft/tags/), to the convention tags NeoForge and Fabric mods
share (data/c/tags/), and sources added to vanilla's atlases
(assets/minecraft/atlases/), which follow vanilla's own as a reload appends them.
Templates and sounds name vanilla blocks; unknown keys are refused.
Some Java mods ship their files under other names than their mod id (Towns and Towers, mod id t_and_t, under
kaisyn and towns_and_towers). A content mod lists those in its manifest:
id = "t_and_t"
api = "0.2"
capabilities = ["registries"]
namespaces = ["kaisyn", "towns_and_towers"]
A listed namespace is the mod's as its id is: its assets/ and data/ files load, and the game reads its tags,
recipes, names and loot. Blocks, items, creative tabs, mob effects, entity types and menu types may be declared in
data/<owned_namespace>/horizon/. The same owned set governs code declarations.
For example, a package with ID ported_farmersdelight and
namespaces = ["farmersdelight"] can declare farmersdelight:cooking_pot
and its farmersdelight: block entity type without changing saved-world IDs.
The namespace collision fence below still applies. minecraft, c and
horizon cannot be listed. Two mods that are on may not own the same namespace (one's id or listed namespace being
the other's): the game keeps the first in load order and leaves the other's content out, with "mods A and B both use
the namespace N; turn one of them off" in the log. Paths use vanilla's resource characters, at most 65,536 files, 16
MiB each and 512 MiB in all. A mod whose content is refused is left out, with the reason in the log. The game reads
this content into its block and item tables (horizon_client::mod_content, modpacks.md). A tag entry
naming an id nobody registers (a block a port left out, another mod's item) is dropped, so the rest of the tag still
loads; vanilla would drop the whole tag.
Tags merge as a data pack's do. Every mod's file for one tag (vanilla's data/minecraft/tags/, the convention
data/c/tags/, or another mod's) adds its values after vanilla's and the mods' before it, in load order. A file with
"replace": true first drops what came before it, vanilla's entries and the earlier mods', and the mods after it
still add theirs, as a higher data pack does. Since the result depends on load order, the game says so in the log:
"mod towns replaces data/minecraft/tags/worldgen/biome/has_structure/village_plains.json: vanilla's entries and those
of adds are left out, ...". Tags are the only minecraft files a mod may hold: its other content stays in its own
namespaces.
Connecting a block to fences, walls or panes
To make a narrow post connect to oak and other wooden fences, add its id to both
data/minecraft/tags/block/fences.json and data/minecraft/tags/block/wooden_fences.json:
{ "values": ["mymod:rope_post"] }
Use only fences.json to join nether brick fences. A fence checks that the neighbour is in
minecraft:fences and that both blocks agree on membership in minecraft:wooden_fences.
A sturdy face toward the fence can also connect, subject to vanilla's connection exceptions.
Fence gates use their own orientation rule.
Use data/minecraft/tags/block/walls.json for a narrow block that should join walls, iron bars
and glass panes. The tags tell vanilla neighbours to draw their arms. Give the custom block
its own model and shapes for any arms it needs to draw.
A mod adds to structure sets, template pools and processor lists that are not its own (vanilla's villages, another
mod's houses) with a set, pool or processor list file in its own namespace holding horizon:add_to, a key vanilla data
never has. Its entries are appended to each set, pool or list named, after every mod's own files, by
horizon:priority (lower first, 1000 when it has none), then in mod and file order; the file is no set, pool or list
itself. A name nobody defines is skipped.
{ "horizon:add_to": "minecraft:villages",
"structures": [ { "structure": "mymod:village_beach", "weight": 10 } ] }
A pool addition holds "elements" as a pool does, a processor list addition "processors". This is what
Lithostitched's add_structure_set_entries, add_template_pool_elements and add_processor_list_processors modifiers
do on NeoForge and Fabric, and horizon-port-pack carry writes the last two from those.
A mod's own configured and placed features (data/<ns>/worldgen/feature/, placed_feature/) generate in
biomes it does not own (vanilla's) through NeoForge's biome modifiers, files in data/<ns>/neoforge/biome_modifier/
(older Forge mods: data/<ns>/forge/biome_modifier/, forge: types). add_features appends its features to one
generation step of every biome in biomes; remove_features takes them out of its steps (every step when it lists
none); none does nothing. biomes and features are an id, a #tag or a list of ids; biomes may also be
NeoForge's any, or, and or not. Every addition comes before every removal, each in mod and file order, and a
feature already in the step is not added twice. Configured features use 26.3's worldgen/feature/ directory
(registry minecraft:worldgen/feature).
Spawn modifiers also apply to those biomes: add_spawns appends spawners (one object or a list with type,
weight, minCount and maxCount) to each entity type's category, converting the count range to 26.3's count;
remove_spawns removes entity_types from every category. add_spawn_costs sets spawn_costs from spawn_cost
(energy_budget and charge) for its entity_types, and remove_spawn_costs removes them. Entity type selectors
accept an id, #tag, list of ids, or NeoForge's any, or, and or not.
Carver modifiers edit the biome's carvers: add_carvers appends carvers (an id or a list of ids) that it lacks, and
remove_carvers takes them out. They read the root's worldgen/carvers.json for the ids that exist, so a carver a
data pack adds is not known to them, and carver tags are not read. NeoForge's docs call these legacy carvers: noise
caves are not carvers.
NeoForge calls a modifier once in each of five phases (BEFORE_EVERYTHING, ADD, REMOVE, MODIFY,
AFTER_EVERYTHING, in that order; its public biome modifier documentation). Horizon applies every addition, then every
removal, then every MODIFY modifier, in mod order within each. NeoForge's own modifier types only add and remove;
MODIFY is for the single values (climate, colours) that a mod's own modifier type changes, so Horizon's is its own
type, horizon:modify_biome, which sets any of temperature, downfall, has_precipitation, temperature_modifier
(none or frozen) and effects colours (sky_color, fog_color, water_color, water_fog_color,
foliage_color, dry_foliage_color, grass_color), with vanilla's biome JSON names, on its biomes:
{
"type": "horizon:modify_biome",
"biomes": "#minecraft:is_forest",
"temperature": 0.3,
"has_precipitation": true,
"effects": { "sky_color": 12632319, "grass_color": "#80a0ff" }
}
What the file leaves out stays. A colour is an integer 0 to 0xFFFFFF or #rrggbb; the biome keeps #rrggbb, with the
sky, fog and water fog colours as its minecraft:visual/... attributes, where 26.3 keeps them. A value of the wrong
type, a colour out of range, an unknown colour name or a temperature_modifier other than none or frozen skips that
modifier with a warning naming the mod and file. BEFORE_EVERYTHING and AFTER_EVERYTHING have no modifier type
here, so the API names no phase for them.
A null for foliage_color, dry_foliage_color or grass_color removes that override from the biome's effects, so the
colormap tints it again; removing one the biome lacks changes nothing. The other colours are required or are attributes,
so a null there skips the modifier with a warning. The removal form is inferred from the title of NeoForge's change
"Add ability to remove color overrides in BiomeSpecialEffectsBuilder" (neoforged/NeoForge#3528, 26.3.0.19-beta), whose
field list is not public; it is not confirmed against NeoForge's code. In code, foliage_color, dry_foliage_color and
grass_color are Option<BiomeColourChange> (Set { rgb } or Remove).
A modifier also sets the biome's ambient and music attributes, in an attributes object with the vanilla keys and
values of a biome file (warped_forest.json has all of them). Each key present replaces the biome's whole value;
the keys it leaves out stay:
{
"type": "horizon:modify_biome",
"biomes": "minecraft:plains",
"attributes": {
"minecraft:visual/ambient_particles": [{ "particle": { "type": "minecraft:ash" }, "probability": 0.25 }],
"minecraft:audio/ambient_sounds": {
"loop": "minecraft:ambient.warped_forest.loop",
"additions": { "sound": "minecraft:ambient.warped_forest.additions", "tick_chance": 0.0111 },
"mood": { "sound": "minecraft:ambient.warped_forest.mood", "tick_delay": 6000, "block_search_extent": 8, "offset": 2.0 }
},
"minecraft:audio/background_music": { "default": { "sound": "zinc:music.dunes", "min_delay": 12000, "max_delay": 24000 } },
"minecraft:audio/music_volume": 0.5
}
}
The particle list may also be written { "argument": [...] }. A particle or sound is a namespaced id and a chance or
volume is 0 to 1; the mood needs its sound, tick_delay, block_search_extent and offset; additions is one
object or a list; a music entry (default, creative or underwater) needs a sound and independently nonnegative min_delay and
max_delay. Any other attribute, or a value that is not valid, skips the modifier with a warning naming the mod,
file and attribute. A client plays these from the biome data the server sends. In code, ModifyBiome has
ambient_particles, ambient_loop, ambient_additions, ambient_mood, background_music and music_volume; the
three ambient sound fields together make the one ambient_sounds value, and a value out of range refuses the mods at
startup.
When they apply. Modifiers are applied while the world's worldgen data is loaded, before any chunk is generated: the content build writes the modified biomes into the content root, and when a world opens, horizon_client::vanillagen::biome_modifiers_over_packs applies them again over the world's data packs (shared_data_with for the generator, packed_biomes for the game's biome table), so a pack that replaces a vanilla biome keeps the mods' changes and a pack's own biome a tag selects gets them. Modifiers a mod declares in code (Registrar::declare_biome_modifier) register before any world opens and are read at the same moment. The generator's data is loaded once per bundle and set of packs and shared by the world's dimensions, so a modifier never applies halfway through a world. The dedicated server is the same game run headless (horizon-client --dedicated, server.md), so it takes this path too; horizon-server only supervises it.
A biome holder set can also use Horizon's own horizon:climate type, alone or nested in those boolean sets; it is not
a NeoForge format. It filters each biome's base temperature and downfall (the names in
Fabric's public biome weather API),
not its positional temperature. Each range takes min, max, or both, inclusive; an omitted bound is unbounded. At
least one field and one bound per range are required. Unknown keys, non-numbers and inverted bounds stop the content
load. Climate sets apply only to biomes, not features.
{ "type": "neoforge:and", "values": ["#minecraft:is_overworld",
{ "type": "horizon:climate", "temperature": { "min": 0.8 }, "downfall": { "max": 0.5 } }] }
{ "type": "neoforge:add_features", "biomes": "#minecraft:is_overworld",
"features": "zinc:ore_zinc", "step": "underground_ores" }
A code mod declares the same modifiers without a file: Registrar::declare_biome_modifier(path, modifier) takes a
BiomeModifier (its biomes, and an operation: AddFeatures, RemoveFeatures, AddSpawns, RemoveSpawns,
AddSpawnCosts, RemoveSpawnCosts, AddCarvers, RemoveCarvers or ModifyBiome, whose Option fields are the
values above). BiomeModifier::json renders it as the file above, so a declared modifier and
a shipped one are applied by the same code, in the same order (additions first, then removals, then modifications, mods in dispatch
order, declarations in registration order). An id outside the mod's namespace or that is not a file path, a repeated
id, an empty list, a name that is not namespaced, an unknown generation step, a spawner of weight 0 or with its
smallest group above its largest, a spawn cost that is not finite, a climate number that is not finite, a colour above
0xFFFFFF and a temperature modifier other than none or frozen refuse the mods at startup, naming the mod.
Declared modifiers are
read by the API today; the game does not apply them until it feeds them through the content loader (tracked on
arismoko/horizon#2732).
The modifiers rewrite the biomes when the content root builds, and the generator orders every biome's features as
vanilla's FeatureSorter does, so an added feature takes its place in that order (its random seed follows from it). A
biome no modifier names keeps its text, and a world without mods keeps vanilla's biomes byte for byte. A modifier
naming a biome, biome tag, feature, feature tag, entity type or entity type tag the game doesn't have (another mod's, a
#c: convention tag), or of a type not read yet (a mod's own serializer), is skipped with a warning
naming the mod and file; the mods still load. A malformed modifier of a type
that is read (an unknown step, a missing field, minCount > maxCount) stops the build, and the game starts without
content mods ("content mods not loaded: ..." in the log). Feature tags are vanilla's for now (#tag in features
doesn't read a mod's placed_feature tags yet); a world's data pack that replaces a biome replaces the mods' changes to
it too.
A biome of your own. A mod's data/<ns>/worldgen/biome/<path>.json (any namespace but minecraft) adds a biome.
The game loads it into its biome table after vanilla's biomes, sorted by path and then namespace, so vanilla's biome ids
never move. A joining client receives it in the biome registry with its data even when it has the vanilla pack, builds
it from that data, and plays its colours, ambient particles, ambient sounds and music from it; a client with the same
mod already has it. Its network id is its place in the registry sorted by path, then namespace, as in vanilla.
Saved worlds name a column's biomes, not their ids: a world saved before the mod existed holds only vanilla names and
loads unchanged; a world saved with the mod and opened without it reads the mod's biomes as plains, and the next save
writes plains. A mod biome appears in the world only where a dimension's biome source or a worldgen file uses it.
A pool element may be one of Lithostitched's: lithostitched:limited (delegate, limit, min_depth) places its
delegate at most limit times in one structure start, and lithostitched:guaranteed (delegate, count,
min_depth) is tried before its pool's other elements at each jigsaw that reaches that pool (as target or fallback)
until count are placed. Neither goes below a piece shallower than min_depth (the start is at depth 0).
A structure (and any of its pool elements, in place of the structure's) may hold
horizon:enhanced_terrain_adaptation: MoogsStructureLib's terrain kernel, which carves or buries terrain around the
pieces on top of vanilla's terrain_adaptation. kernel_size and kernel_distance shape the kernel; top and
bottom (carve, bury or none) act above and below a piece's floor; bottom_offset, padding (x, top,
bottom, z) and MoogsStructureLib's band (bottom, top, piece_heights) move the box it measures from. On a
structure, rigid_junctions adds rigid pieces' junctions as well and inflate grows the start's box by the kernel
radius. kernel_size 0 is none.
{ "horizon:enhanced_terrain_adaptation": { "kernel_size": 24, "kernel_distance": 24,
"top": "carve", "bottom": "none", "rigid_junctions": true, "inflate": true } }
Passenger attachment points
An entity type can set passenger_attachments to seat offsets relative to its feet.
A list of exactly three plain numbers is one [x, y, z] point, not three heights.
For multiple seats use nested points, for example [[1, 2, 3], [-1, 4, 5]].
Seats follow passenger order; extra passengers use the last point. Offsets scale with
the entity and rotate with its yaw.
Vanilla FishingHook.shouldStopFishing tests exact Items.FISHING_ROD identity, while the renderer chooses the holding arm using instanceof FishingRodItem. Broadening these checks for content rods is intentional.
Ambient additions accept any finite tick_chance, without clamping to the unit interval. Music delays need not be ordered.
Native mods
Native mods
Behaviour that needs code (a new key, a HUD, a tool that changes how mining works) is a native mod.
It is one flat Rust project with horizon-mod.toml, Cargo.toml and src/, beside its assets/ and data/.
Start with cargo horizon-mod new my_mod --template native and implement the typed hooks;
raw injections are a rare escape hatch (Injections). How the launcher builds one, what it pins and why it is not sandboxed:
native mods.
The typed hook API is generated automatically at build time, in debug and release builds alike; its generated
Rust files are build output, not files to regenerate or commit. Engine contributors adding a hook only run
cargo run -p xtask -- mod-api to fill its hand-written placeholder body
(Adding a hook).
A native mod's config files go in the game's shared config/ folder, beside options.txt (on a dedicated server,
beside server.properties), as Fabric and NeoForge mods' do: the mod picks its own file names, such as
config/<mod id>.json or a folder config/<mod id>/. It reads and writes them with Host::config_files(), which is
bound to the calling mod and refuses any other mod's files. A mod that lists its files with
native_register_configs gets them read for it, written from their defaults when missing, and handed over again
when edited (native_config_loading, native_config_reloading, as NeoForge's ModConfigEvent), and a world can
keep its own server config in serverconfig/ (native mods). Settings kept with set_setting stay in horizon-mods/.horizon-mod-data/<id>/.
setting and set_setting are bound to the calling mod and its registered keys: naming another mod's ID or an unregistered key never reads or changes a file.
Typed reads cache values by data folder and key; a successful set_setting invalidates that key, while a failed write leaves the cached value unchanged. Raw setting reads access the file.
Register typed settings with Registrar::register_setting to put them on Options > Mods; edits persist before
setting_changed runs, and only the owning mod receives that event.
8c838a6b381b93150deaeed2057acd1
For a client HUD's session information, use the read-only
ClientHost::client_session() service and the local PlayerRef's
block_breaking and ambient_mood reads rather than raw injections. Missing
sessions, external display names and memory addresses remain explicit optional
values; all counters stay local. See client session and local-player status
for their exact availability and frame-time semantics.
native_compute_fov_modifier(player, base, modifier) runs on the client each tick for the local player's FOV scale, after vanilla effects and before zoom and camera easing. player contains the calculation inputs and base is the original scale. Non-finite modifiers or values outside 0.01..=10 fall back to base. It does not affect a spectator camera on a mob.
The client hook native_render_name_tag(entity_id, kind, vanilla_visible, visible, text) runs at player and mob, armor stand, and item frame name decisions before shared geometry. Set visible to override vanilla and text to Some literal replacement text (NeoForge RenderNameTagEvent). Distance and entity availability still apply; the local player's tag is considered only when its own camera and settings gates allow it.
Native mods are Rust projects that implement the game's typed hook API (impl Hooks). Start with
cargo horizon-mod new my_mod --template native (Trait mods).
Each project has one horizon-mod.toml, with exact build pins in [native], and ships as one .hmod.
For the rare case the API does not cover, internals injections use compiled raw slots when available;
unlisted supported targets use a source rebuild. See Injections.
An injection is an escape hatch; request a typed hook for the behavior it needs.
Native mods are native code with the player's rights, not sandboxed. The first install shows a trust notice, and every build waits behind a red warning; the registry lists one only after a review (its later releases are checked by machine). They play in a game of their own, built for the instance they are installed in.
The game's Mods screen only lists code mods (it has no switch). A code mod's settings page, when it has one, opens from the Config button. The launcher decides which mods are on. A browser install that finishes after a world opens waits until the world closes before activating its content.
Villager handles preserve the full signed integer level (at least 1), including values above the five vanilla trade tiers. Higher levels use the master badge and do not gain further vanilla trades.
Measuring native mod calls
An administrator with permission level 3 can run /modprofile start, exercise the mod,
then run /modprofile stop. Profiling is off by default. Starting twice or stopping without
an active session reports an error. /modprofile report prints the last completed summary
without writing a file or stopping an active session. Feedback goes only to the sender.
The same controls work through horizon-ctl command modprofile start (and stop or report).
Stop saves one local, atomically written profiles/modprofile-<timestamp>-<counter>.json
under the game's folder. The export is bounded to 8 MiB; a save failure is reported and the
stopped summary remains available. Load the file locally in Firefox Profiler's Marker Table;
Horizon never uploads it. The raw Gecko version 37 markers contain mod/hook/Part identities,
counts, exact nanosecond timings and log2 histograms, never arguments or player/world data.
Timings are inclusive: an outer call includes nested calls. These are process-wide aggregates,
not invocation traces or sampling shares. This first command release measures native guarded
calls only; wasm timing and a dedicated profiling guide follow separately.
Server services and player handles
Server hooks reach commands through host.commands(): run_command(line) uses
console permission level 4 and the existing console command path, while
feedback(text) answers the current command source. host.messages() provides
broadcast(text) (including the broadcast hook and server log) and
text_allowed(text) (the server chat filter).
On the client, host.chat_line(Text::literal("Local notice"))? adds a line
only to the local chat HUD and history, without sending a packet or firing
client_chat_received. Handle its Result<(), mod_api::TextFormError>: text exceeding the
boundary's size or nesting limits is refused before it reaches the HUD.
host.players().server_players() returns Vec<PlayerRef> in connection order;
host.players().acting_player() returns the optional PlayerRef whose state is
swapped in for this hook. The online list includes connected players even during
nested player turns; a displaced body's reads still refuse where they did before.
host.players().player_by_name(name) finds a connected real player by account
name, case-insensitively, or returns None; it does not match nicknames or fake
players. host.levels().list_levels() returns loaded server level handles in
vanilla dimension order, or an empty list before a server world opens. Both
services are available through &dyn ServerHost and do not mutate the world.
Read identity through player.uuid(), player.name(host),
player.permission_level(host) and player.address(host). The address is the
connection IP without a port (the forwarded address behind a Velocity proxy),
empty for an acting player with no known connection. Name, permission and address
reads retain connected identities during displacement and refuse stale handles.
player.level(host) returns the authoritative LevelRef, and
player.dimension(host) returns its dimension Identifier, including registered
custom dimensions. These reads take &dyn Host; dedicated and integrated servers
read the player's current dimension, while a client reads its local player's level.
They return None for unknown,
disconnected, replaced or stale player handles. Connected players retain their
current dimension through nested guest turns; the level may be parked rather
than the dimension whose tick is currently running.
Use the entity and living handles for other world-state reads.
No snapshot of event data is returned by these services.
Send to one player with player.tell(host, text), action_bar(host, text) or
tab_list(host, header, footer). set_nickname(host, nickname),
set_sleep_ignored(host, ignored) and hide(host, permission) keep their existing
server behavior. Messaging reaches the acting player and other connected players,
including displaced online records; it refuses stale or disconnected identities.
Vanish state is stored by UUID and can be changed with a retained handle after
disconnection, until its world epoch closes. It cannot target arbitrary offline
UUIDs; retain the handle value, not the borrowed service. Nickname and sleep
writes still require an online player. The settings survive reconnects.
A player handle also answers the game's own rules for that player, with the checks and state
the real actions use: may_use_item_at(host, pos, face, stack) (Player.mayUseItemAt: builders
always may, adventure and spectator players only with a matching can_place_on) and
can_harm(host, other) (ServerPlayer.canHarmPlayer: the pvp rule and their teams).
respawn_config(host) reads the respawn point (Some(None) for none) and
set_respawn_config(host, config) writes it as /spawnpoint does, player_set_spawn veto
included. text_filtering(host) is the option the player's client announced. For the night skip,
sleep_ignored(host) reads what set_sleep_ignored left, counts_for_sleep(host) says whether
the player is counted, and sleep_timer(host) / set_sleep_timer(host, ticks) read and write
Player.sleepCounter (0 to 109; 100 is slept long enough). Reads take a Host, writes a
ServerHost.
These small services exist only on ServerHost, not Host, ClientHost or a
restricted level host. Native and wasm mods use the same calls: service methods
are generated imports, and acquiring a service just reborrows the current call's
frame resource. Block and fluid scheduling stays on LevelTicksHost, and
tick_stats() stays on ServerHost.
Typed server commands
Register each server usage with register_commands and Command. The
run_mod_command event carries a CommandInvocation,
not a raw line to split. Match its literals to your command path, then read named
values through command.argument(name) and CommandArgumentValue. An integer is
already an i32; a player argument is a live PlayerRef, not selector text.
Return Flow::Cancel when your listener handles the command.
The server parses and checks permissions before resolving arguments or calling a
listener. Omitted optional arguments are absent, including an omitted optional
player: they do not silently select the source player. A present player argument
must select exactly one online player; missing players or invalid input produce
command feedback without calling the listener. Word and greedy text arguments
are strings, numbers are finite doubles, booleans are typed, and item arguments
are component-bearing stacks of one item. Block positions are resolved and floored
from the source's world, relative or local coordinates without loading chunks.
A player's source is their position and rotation. The console has no source
entity and uses the Overworld spawn's block corner with zero rotation, so @s
cannot resolve there. Every source receives the same owned data, on native and wasm.
event.source is a non-exhaustive CommandSource: Player { player, .. },
Console { .. }, CommandBlock { position, level, .. },
CommandBlockMinecart { entity, .. } or Function { id, .. }.
source.permission() returns the effective PermissionLevel: functions use
function permission; blocks and carts use game-master permission. execute as,
at and in change argument resolution, not the origin. Feedback through
host.commands().feedback honours the underlying source's silence and track-output.
Custom argument types
Register a value kind once from Hooks::register, then use its namespaced id as an
argument's kind:
use mod_api::{
Command, CommandArgumentError, CommandArgumentKind, CommandArgumentType, CommandSuggestion,
Hooks, RegisterCommandsEvent, Registrar, RegistrationContext, ServerHost, Text,
};
struct Machine;
impl CommandArgumentType for Machine {
fn kind(&self) -> CommandArgumentKind {
CommandArgumentKind::Word
}
fn parse(&self, input: &str) -> Result<String, CommandArgumentError> {
match input {
"press" | "furnace" => Ok(input.to_owned()),
_ => Err(CommandArgumentError::new(Text::translate(
"example.commands.machine.unknown",
vec![Text::literal(input)],
))),
}
}
fn suggest(&self, prefix: &str) -> Vec<CommandSuggestion> {
["press", "furnace"]
.into_iter()
.filter(|machine| machine.starts_with(prefix))
.map(CommandSuggestion::new)
.collect()
}
}
#[mod_api::horizon_mod]
impl Hooks for MyMod {
fn register(&self, r: &mut dyn Registrar, _: &RegistrationContext) {
r.register_command_argument("machine", Machine);
}
fn register_commands(&self, _: &mut dyn ServerHost, event: &mut RegisterCommandsEvent<'_>) {
event
.commands
.push(Command::new("machine use <machine:mymod:machine>"));
}
}
The server parses the argument with parse before calling run_mod_command; a
rejected value shows the returned Text and no listener runs. A valid value arrives
as CommandArgumentValue::Custom { kind, value }, where kind is mymod:machine
and value is the canonical text parse returned. suggest is asked for the
completions while the player types.
CommandArgumentKind::Word takes one word of ASCII letters, digits and _ - . +; the
word ends at the first other character, a space included, so a value with : or /
needs Greedy, which takes the rest of the line and must be the usage's last argument.
Players whose client does not know the type still
parse and complete the command: the server sends the argument as a Brigadier string
of that shape with minecraft:ask_server, and their chat asks the server for the
suggestions. (Fabric sends the registered argument type's id, which an unmodded
client cannot read; Horizon's string keeps the tree valid.)
The namespaced id is in the registering mod's namespace; an id registered twice, a
name in another namespace or an empty name refuses the mods at startup, naming the
mod. A client mod registers client command argument types the same way; they are
available to register_client_commands.
player_command stays player-only pre-execution cancellation (NeoForge's CommandEvent), including vanilla commands.
When the usages a mod registers change at runtime, call
host.commands().refresh_commands() on the server. At the start of the next
server tick, every mod is asked to register again. The server rebuilds the typed
command dispatcher and sends each player's tree after player_commands_sent
filters it, as vanilla does after /reload. Repeated requests before that tick
coalesce; without a world open to players, nothing is rebuilt.
Observing advancement progress
Subscribe to player_advancement_progress to inspect the whole advancement immediately after
one criterion is granted or revoked, before its completion rewards. event.progress.criteria
includes obtained and unobtained criteria; obtained_at is the original saved grant date or
None. requirements lists the OR groups, completed_requirements counts satisfied groups,
and done is the game's completion result. A player-issued /reload mutates and snapshots
the acting player's live progress, including during a nested turn. For a suspended outer
player, reload grants still use the displaced hub record; full owner-correct reload awards
for suspended and inactive players remain tracked in #1240.
Keep event.progress.clone() if it is needed later: the owned AdvancementProgress snapshot
cannot change the player's progress and is not updated by subsequent events. No-op grants and
revokes do not fire. The snapshot uses the same plain-data bindings in native and wasm mods.
Filtering toast notifications
Implement Hooks::toast_add to filter a client notification before it enters its
queue (NeoForge ToastAddEvent). The hook receives a read-only ClientHost and
ToastAddEvent: kind is a ToastKind, and title and description are mutable
text components. Return Flow::Cancel to discard only that candidate, or
Flow::Continue after optionally replacing either component. No slot, animation
or toast sound is started for a cancelled candidate.
The families are Advancement, Recipe, Tutorial, System and Other (including
now-playing music). Match non-exhaustively. For advancement toasts, the title is
the task/goal/challenge label and the description is the advancement's name, not
its advancement-screen description. Recipe notifications keep their rotating
station/result icons; tutorial hints keep their progress bar. Edits change only
the displayed text, not these identities or lifetimes. Setting the optional
description to None removes that line.
The hook runs for new additions, not in-place addOrUpdate changes to an existing
recipe or system toast. A cancelled tutorial hint is not retried every frame;
a later, different hint is a new candidate. Title-screen backup/access failures,
narrator notices, pack download/failure and chunk IO notices all use the same
admission point. Client-lib prepares title candidates and Game admits them with
a host before applying them; candidates produced during the initial resource
loading screen wait until that handoff. The pause screen's persistent song label
is HUD content, not a queued toast, and is not filtered by this hook.
Native mod source revisions
A native mod targets one exact Horizon source revision: horizon in its horizon-mod.toml [native] section.
The same section pins game and the exact mixins commit. Match the mod to the game revision it will run with,
and rebuild it after game updates. A source pin is not a promise that a later game or launcher will keep
building the mod unchanged.
An installed native build with a missing or invalid build record must be rebuilt from an installed Horizon version rather than used as a source revision.
Hook points are functions the game calls at fixed places. Their names, parameters and call sites belong to the game source and may change. Per-hook catalog versions describe individual contracts, not minimum game versions or a cross-release compatibility range.
Hooks are declared in Rust now, not in JSON: a hook author adds a #[hook(...)]
declaration, with its /// doc, to a file under crates/horizon-mod-vocab/src/hooks/ (a new file for a new area, named
for it). A host method is not declared there: it goes in the area trait of
crates/horizon-mod-vocab/src/host/<area>.rs, with #[access(WorldOnly)], #[access(Metadata)], #[access(ClientSetup)] or #[access(PublishedResources)] on each method. The inherent Game method has the same name. The generator writes the access check and full-path call into Cargo's build output; do not write a game-side forwarder. The #[horizon_mod_api_macros::host_area] attribute generates panicking defaults for test doubles. The generator discovers area files without a shared module list. native-hooks.json and
tools/native-hook-fragments/ are retired, and a file there fails the build. Collection updates
the catalog, not a global API number, source comments or documentation histories.
Hand-written API types have a selected public surface in crates/horizon-mod-vocab/src/exports/<area>.rs:
module declarations and re-exports stay in their source area's fragment. Engine-only re-exports sit in a
separate section of the same fragment and keep their existing paths. The generator discovers all fragments
and includes each section at its original scope. A new hand-written area adds one fragment, while
catalog-generated hook types need no export-list edit. Do not add declarations or re-export lists to
lib.rs.
Runtime packages and identity
The launcher generates the game's embedded native inventory from the selected horizon-mod.toml
manifests: mod id, version and side. Cargo package metadata and hook factories do not supply identity.
The game validates each compiled mod's package at
<executable directory>/horizon-native-mods/<id>/ before installing hooks or starting a world.
Missing, unreadable, content-only or identity-mismatched packages refuse startup with the affected
mods and paths; restore the packages exported with that executable or rebuild it. There is no
build-record fallback and no discovery of native runtime packages from the working directory.
Content-only mods still follow the working directory, player toggles and explicit --dev-mod paths;
a content package cannot shadow a compiled mod's id.
Move the executable, prepared resources and canonical package directory together. The copied
projects retain their flat manifest, crate, assets and data. The game's mod list and crash details
use its embedded inventory, including code-only mods. Display names and descriptions come from the
validated manifests; Controls bindings are declared through Registrar::register_key_mapping, not a separate NativeMod metadata hook.
native-mods.json is provenance and rebuild status, not game identity, authentication, announcements
or startup input. The launcher retains it for build/status displays. The separate horizon-server
supervisor may report the adjacent record as a provenance snapshot, not a query of the running engine.
Its SHA-256 values and revision describe the built binary's inputs, not the running mod set.
The launcher prepares jar data from canonical manifests as non-authoritative candidates; the engine
validates identity against its embedded inventory. Original-jar pack order and
whole-package refusal rules are in Original Java jar layers.
A bundle
The project and its .hmod have the same flat layout, with no mod-id directory level inside the archive:
my_mod/
horizon-mod.toml # the only manifest; mod identity and build pins
icon.png # optional launcher icon
assets/my_mod/
data/my_mod/
Cargo.toml # one crate; Cargo metadata is not mod identity
src/lib.rs # impl Hooks
src/frame.rs # optional injection callback module
Without Cargo.toml, it is a content mod. Code requires both Cargo.toml and [native];
inconsistent declarations are refused. The mod's id and version come only from horizon-mod.toml,
not Cargo's package name or version. An id is a plain [a-z0-9_]{1,64} name, also used as its resource namespace.
A code mod has no rust-toolchain.toml: the pinned Horizon commit decides the toolchain that builds it.
There is no input native/ or rust/ directory, horizon-native.json or hand-written horizon-mixin.toml.
The launcher writes the tool's manifest and stages the crate itself; those internal paths are not package layouts.
The manifest keeps the content fields, including the exact api = "0.2"
contract and capabilities = ["registries"]. A code mod adds:
[native]
game = "26.3"
horizon = "<exact 40-character lowercase Horizon commit>"
mixins = "<exact 40-character lowercase horizon-mixins commit>"
The placeholders above must be replaced by exact commits; the native template fills them in.
Unknown fields are refused. icon.png has no manifest key: when present it must be a valid,
nonzero square PNG, at most 512 × 512 pixels and 256 KiB encoded. It is optional for both content
and code mods; the scaffold does not create one.
Injections belong in the same manifest as top-level [[inject]] tables, with access = "api" or "internals". The decision ladder, the fields and the
forms are in the modding book: Injections.
Dimension keys
LevelRef::dimension returns a DimensionKey, a small Copy value that names the dimension
for as long as the game runs, custom dimensions included. Compare it with another key or with
DimensionKey::OVERWORLD, NETHER and END; level.dimension().id(host) gives the name
(minecraft:the_nether). On a client, host.level(name) gives the level it shows under the
name the server calls it, whether or not that is a vanilla dimension.
Dimension shape
LevelRef::dimension_shape reads the loaded server level's build range, logical height,
skylight and ceiling from its dimension shape. It accepts &dyn Host, including when the
level is parked, and returns None for a stale or unavailable level or on a client. It does
not load a level or allocate.
Dimension type
level.dimension_type(host) reads a level's dimension type (Level.dimensionType() and its
gameplay attributes) as a LevelDimensionType: the build range, coordinate_scale, ambient_light,
has_fixed_time, infiniburn, the monster spawn light range, the sky and the gameplay switches. A
level on a vanilla type returns that type's values, a registered dimension returns what its
DimensionSettings set, and a data pack's dimension type counts the same way. It returns None for
a stale handle, and on a client it reads the level that window shows; a client of another server
reads the Overworld's values for a level that server's mods added (the game itself plays that level
by the type the server sent).
Registering a dimension
register_dimension gives every world a level of the mod's own. Call it once, at startup, in
Hooks::register:
#[mod_api::horizon_mod]
impl Hooks for MyMod {
fn register(&self, r: &mut dyn Registrar, _: &RegistrationContext) {
r.register_dimension(
"pocket",
DimensionSettings { min_y: -128, height: 448, logical_height: 448, ..Default::default() },
);
}
}
The level is mymod:pocket. DimensionSettings sets the build height, has_skylight,
has_ceiling, the generator (minecraft:noise by default), its noise_settings and the level's
one biome; the default is the Overworld's shape and terrain over plains. It also sets the rest of
the dimension type the game reads: has_fixed_time, has_ender_dragon_fight, coordinate_scale,
ambient_light, infiniburn, the monster spawn light range and block light limit, the skybox,
the cardinal_light and the gameplay switches (water_evaporates, fast_lava,
respawn_anchor_works, nether_portal_spawns_piglin, piglins_zombify, snow_golem_melts,
can_start_raid, sky_light_level) and the bed rule (bed_can_sleep, bed_can_set_spawn,
bed_destroy_on_use, bed_destroy_on_leave: always, when_dark or never, and whether a bed
explodes or breaks; the same four with straw_bed_ for the straw bed). A field left at its default is the Overworld's, and the game
refuses a value out of range (a coordinate_scale outside 0.00001 to 30000000, an ambient_light that is not a number, an unknown
skybox, a bed rule that is none of the three) at startup with a message naming the mod. The
coordinate_scale is what execute in mymod:pocket scales a position by, and what a portal
between two levels scales by (DimensionType.getTeleportationScale); a position given to a
teleport call is taken as it is. The level opens, ticks, saves and closes as the
Nether and the End do, in dimensions/mymod/pocket/ of the world, and clients receive its
dimension type when they join. A client of a server takes the whole type, not only the build
range: the level's fluids, mobs, beds and time follow the type the server sent, as on the host,
whether or not the client has the mod that registered the level.
sky_color, fog_color (#rrggbb) and cloud_height set the sky overhead, the fog at the
horizon and the clouds' height; left as None they are the Overworld's. A client of a server draws a
level of a mod or a pack with the sky the server's type gives it, as the host does.
day_cycle picks the timelines and the clock the dimension type names: overworld (the day and moon
cycles, the villagers' schedule and the Overworld's clock, the default), nether (the schedule only,
no clock), end or none. It sets how the level's sky and light change over the day in the host's
world, whether monsters burn in daylight, and which clock /time reads there. level.dimension_type(host)
gives the timelines and default_clock back. The day time itself is the world's one clock.
The level's biomes are the one biome everywhere unless biome_source says otherwise:
BiomeSourceKind::Checkerboard alternates the biomes in cells 2^biome_scale chunks wide,
MultiNoise uses the Overworld's or the Nether's biomes (biome_preset) and End the End's islands.
Points places the mod's own biomes: biome_points lists a BiomePoint per biome, with an interval
((min, max), each from -2 to 2, a single value is (v, v)) for temperature, humidity, continentalness,
erosion, depth and weirdness, and an offset from 0 to 1. The biome whose point is nearest to the level's
climate at a position wins, as in a data pack's minecraft:multi_noise source with an inline biomes list;
the biomes can be the mod's own or vanilla's. A mod's points shape its own level only: placing a mod's biomes
among vanilla's in the Overworld or the Nether is a separate design (#2554). Only Fixed suits a chunk
generator of the mod's own, which writes terrain only.
A dimension in another mod's namespace, an empty name, a name that is not a folder of the world
(lowercase letters, digits and _ . - only, with / between segments that are not empty, . or
..), a vanilla level's id, an id registered twice, a build height the dimension type codec refuses
(min_y and height are multiples of 16, and the level stays between -2032 and 2031) and a
generator nobody registered stop the game at startup with a message naming the mod. The generator
may come from a mod that loads later.
A registered dimension is the level stem and dimension type a data pack would give, below every
data pack: a pack with data/mymod/dimension/pocket.json or data/mymod/dimension_type/pocket.json
replaces that half of it, in the worlds that have the pack.
register_dimension is the same call in a mod built to mod.wasm, as the settings are plain
data. A wasm mod's dimension can name only the game's own generator, minecraft:noise: a chunk
generator is code, and register_chunk_generator is native only.
Sending a player to a dimension
There is no portal to a registered level. A mod sends an entity there with
entity.teleport_to_level(host, dimension, pos, rotation) from a server hook, the call vanilla has
as Entity.teleportTo(ServerLevel, ..). It works for a player, a mob, a boat and a minecart (a
vehicle takes its passengers along).
fn run_mod_command(&self, host: &mut dyn ServerHost, event: &mut RunModCommandEvent<'_>) -> Flow {
let CommandSource::Player { player, .. } = event.source else {
return Flow::Continue;
};
let entity = player.entity();
entity.teleport_to_level(host, "mymod:pocket", [8.5, 100.0, 8.5], None);
Flow::Cancel
}
pos is in the destination's own coordinates: nothing scales it by the level's
coordinate_scale. A level nobody has entered opens first, and a player arrives after the current
tick, when player_teleported and player_changed_dimension report the move. The call
answers whether the move was taken, not whether the player arrived: a level that won't open, such
as one naming a generator nobody registered, logs an error and leaves the player where they were.
It is false for an unregistered dimension and for an entity that cannot change level (an item, an
orb, a projectile); a fake player stays where it is. Nothing builds a platform at the destination, so look for the
ground first, or place one.
Sea level
level.sea_level(host) reads Level.getSeaLevel: 63 in the Overworld, 32 in the Nether, 0 in the End. For a level of
the noise generator it is the sea_level of its noise settings, a data pack's too when it replaces them. A level
of a chunk generator the mod wrote has no noise settings to read: set DimensionSettings::sea_level to give it one
(a level of the noise generator keeps its noise settings'), else it is 63 (-63 for a superflat generator). It is the sea
level the game uses for the level: mob spawning and behaviour (drowned, turtles, phantoms), rain and snow, and maps.
Terrain and aquifers are built with the generator's own sea level, which for a noise level is the noise settings' and
which DimensionSettings::sea_level never changes: it only gives level.sea_level(host) a value for a generator that has
none, so it does not move where the water is.
Day clocks
Each level goes by its dimension type's default_clock: the Overworld by minecraft:overworld, the End by
minecraft:the_end, a level without one (the Nether) by the Overworld's. Every clock the world has met runs at its
own rate and keeps its own total, saved in world_clocks.dat, so a level with a clock of its own has its own day:
its sky, its spawning and its beds follow that clock, and /time in the level sets it.
level.clock_time(host) reads the level's own clock in ticks (None for a level without a default clock);
level.day_time(host) stays the Overworld's, whichever level is asked.
Which level a hook is in
The block-behaviour hooks (neighbor_changed, random_tick, crop_grow, block_changed and the rest) name their
level with a LevelDimension: Overworld, Nether, End, or Custom(key) for any other level, a mod's or a data
pack's. LevelDimension::key() gives the level's key (key.id() its namespace:path) and LevelDimension::of_level
goes back. Match it with a final Custom(_) arm. The game's own rules read the level's own dimension type for these
too (water that evaporates, fast lava, infiniburn, piglin portals), and player_respawn_position may send a player to
a Custom level.
Players, structures and chunk watchers
level.online_players(host) lists the real online players in that server level, including
when it is parked. Obtain a chunk with host.chunk(level, pos), then call
chunk.watchers(host) to ask which players were sent it and still have it inside their
view distance; fake players watch nothing.
level.find_nearest_structure(host, structure, StructureSearch::new(origin, radius)) searches
for a structure id or #tag and returns its structure and locate position. This is a
synchronous, /locate-cost search, not a cheap loaded-chunk lookup: cache results and avoid
per-tick use. The radius counts placement-cell rings (at most 100); stronghold rings are
checked regardless of radius. It does not load or generate chunks.
Saving a level
level.request_save(host) asks the server to save a level, as the autosave and /save-all do, with its
chunks on disk when it ends. The save runs at the start of the next server tick, not inside the call (a hook
may be running in the tick that is saving), and a level asked for twice before then is saved once. The call
answers whether the request was taken. level_save fires with the level's dimension once it was written; a
save that fails fires nothing and is logged.
Inside a hook, use level.request_save(host), not a synchronous save-all command.
While an entity pass or held-out row is borrowed, synchronous saving refuses with vanilla's
commands.save.failed feedback and a failed command result, without writing a partial snapshot.
Requested saves wait for a settled start-of-tick boundary.
Chunk generators
A chunk generator is a mod's code for a level's terrain: vanilla's ChunkGenerator, and
build_terrain its buildTerrain. Register it once, at startup, and name it from a dimension,
or from a data pack's level stem as "generator": {"type": "mymod:void"}:
use mod_api::{
BlockKey, ChunkAccess, ChunkGenerator, DimensionSettings, Hooks, Registrar,
RegistrationContext,
};
struct Floor {
stone: BlockKey,
}
impl ChunkGenerator for Floor {
fn build_terrain(&self, chunk: &mut dyn ChunkAccess) {
let stone = chunk.state(self.stone);
let [x, z] = [chunk.pos().x * 16, chunk.pos().z * 16];
for dx in 0..16 {
for dz in 0..16 {
chunk.set_block([x + dx, chunk.min_y(), z + dz], stone);
}
}
}
}
#[derive(Default)]
pub struct TerrainMod;
#[mod_api::horizon_mod]
impl Hooks for TerrainMod {
fn register(&self, registrar: &mut dyn Registrar, _: &RegistrationContext) {
let stone = registrar.block_key("minecraft:stone");
registrar.register_chunk_generator("floor", Floor { stone });
registrar.register_dimension(
"floor",
DimensionSettings {
generator: "mymod:floor".into(),
..Default::default()
},
);
}
}
There is no Host in world generation: chunks fill on the worker threads, so a generator sees only
the ChunkAccess of the chunk it fills, is Send + Sync, and keeps no mutable state, because two
workers can fill neighbouring chunks at once. The same seed and chunk give the same blocks.
ChunkAccess has the chunk's pos, the world's seed, the level's min_y and height, and
borrowed immutable metadata(). Resolve BlockKeys at registration, then use state or
state_with to construct API-owned BlockStates before the terrain-writing loop, never once per
placed block. set_block skips a position outside the chunk or build height and answers whether
it wrote. The chunk starts as air; invalid state ordinals are refused rather than written.
The generator gives terrain only. The level has no surface, carvers or features, and every chunk
has the stem's one biome. noise_settings means nothing to it.
A generator in another mod's namespace, an empty name, a name that is not a folder of the world (as
for a dimension), an id registered twice and minecraft:noise stop the game at startup with a
message naming the mod. ChunkGenerator and ChunkAccess are not yet bound by the generated wasm worker interface; a wasm
mod can register dimensions naming an already installed generator, but cannot register its own
chunk generator. Structure types use the existing generated worker binding instead.
A level stem naming a generator nobody registered, say after its mod was removed, is an error for whoever opens that level, not for the world: a teleport there fails and is logged, and a player saved there joins a server in the Overworld. Single player refuses to open a world whose player is saved in such a level.
Raid handles
Raid handles returned by LevelRef::raid, raid_at and raids resolve their
own loaded server level even while another dimension is in place. Raid reads,
omen-level writes and stop do not swap levels. Raider lists and living health
come from that level's mobs; raid ids are local to a level. A closed level, an
earlier world epoch or a client refuses the handle. Entity handles in a parked
raid's member list keep their identity; their ordinary entity operations still
require the entity's level to be in place.
Tool transformation previews
LevelRef::block_transform previews a tool against a loaded block in the server level in place.
Pass the live player, hand, stack, position and hit; the owned result describes the new state,
relative durability cost, sound, loot and particle. The call does not apply the result. It fires
block_tool_modification with ToolUse::simulated set to true: listeners must not change the
level during a simulation. A stale or unavailable player or level, unloaded position, invalid face,
or tool with no transformation returns None. The real action fires the same hook with
simulated set to false.
Named hook points
Implement catalog hooks as methods of mod_api::Hooks. An injection does not select a hook by
hook or hook_version: the manifest schema accepts [[inject]] with a target in at,
a crate-root listener in with, and the pinned function hash. See Injections.
Raw frame functions are native escape hatches, not methods on Hooks. For example, an
internals injection at horizon_mod_render::render::world_drawn names its listener with
with; use the current catalog hash and kind = "head". The launcher supplies the
checked raw listener signature, and the ordinary registry owns its priority and lifetime.
Teleport hooks
ender_pearl_teleport version 2 covers player-owned and mob-owned pearls. Its owner is an
EntityRef; listeners can cancel or redirect target. Player owners retain the
acting-player context and mutable fall damage. Mob owners take no pearl damage
and make no endermite roll. Pearl placement does not emit the command-only
entity_teleport notification. The pearl is consumed even when teleportation is canceled.
item_consumption_teleport version 2 covers players and foxes with the random-teleport
consume effect. Its consumer is a LivingRef, and consumed is the stack captured
before use, including count and components. Cancellation ends further teleport
attempts, not consumption. Fox callbacks run before that fox's living AI/movement
and before the next mob ticks, with the full server host. Listener changes to an
actor's item, position or eligibility survive rather than being overwritten by a
stale teleport decision. Command and Ender-entity teleport hooks are unchanged.
Both version-2 contracts replace version 1's player UUID/name fields: use owner
or consumer.entity() to address the actor, and the acting-player context for
player-only operations. Named injections must request hook_version = 2 and bind
the new parameter names; version 1 is not an alias for these contracts.
State flags on the living handles
host.living(entity) and host.mob(entity) make the handles of #7387's handle design, and
the state flags sit where vanilla keeps them: the ones LivingEntity owns read and write through LivingRef, the ones
Mob owns through MobRef. Reads take the shared host, writes the server one; a stale handle or a kind the game keeps
no such state for reads None and writes false, as every handle does.
Through LivingRef: the glide (is_gliding, gliding_ticks, stop_gliding), the sleep of a server player or a
villager (is_sleeping, sleeping_pos, start_sleeping, stop_sleeping, set_sleeping_pos, clear_sleeping_pos),
the jump of the player in place (is_jumping, set_is_jumping), climbing (is_climbing, the same onClimbable
movement uses, including the gliding exclusion for vines), the baby read (baby) and shield blocking
(is_blocking: LivingEntity.isBlocking, an item with blocks_attacks in use past its block delay, for players
and mobs). Server player sleep writes
work for the integrated owner, LAN guests and dedicated-server guests in the level in place, both inside and
outside their own turn; guests in another dimension and fake players refuse all four sleep writes without
mutation. A suspended outer actor is unavailable during a nested turn. Glide counters and stops follow the
same authoritative body across turns. These reads do not add client tracked-entity coverage.
start_sleeping requires a bed with an available sleep shape before changing any state (a villager also refuses an
occupied bed). It moves the sleeper onto that shape and occupies the bed. stop_sleeping stands the sleeper up
beside the bed with the normal wake animation, teleport and pose sync; the usual level pass frees a player's bed.
set_sleeping_pos and clear_sleeping_pos only change a player's sleeping position/pose, without moving them,
occupying a bed or playing a wake animation. The next entity sync publishes the changed pose and bed to clients.
Villagers use start_sleeping/stop_sleeping, not the raw player position setters.
EntityRef::portal_cooldown takes &dyn Host and reads the remaining portal cooldown in ticks from the
server's portal state: a player's own (the acting player inside its turn and other players outside it), and the
level's for mobs, items, experience orbs, boats, minecarts, primed TNT, falling blocks, arrows and the thrown items
and fireballs a chunk saves, fireworks and tridents; an ender pearl's comes from End gateways only. Zero means no
cooldown; None means a kind with no portal cooldown (armor stands, wind charges, the separately modelled dragon and
others), a client, or a stale, parked or otherwise unavailable handle. The read does not load levels.
MobRef::can_use_portal reads the existing server portal decision for a mob in the current level, including mounts and player riders. It takes &dyn Host: Some(false) means the mob is ineligible, while None means a client, stale or unavailable handle, parked mob, or the separately modelled dragon. It does not load a level.
EntityRef::leash_holder takes &dyn Host and reads the live lead of a mob or boat. LeashHolder::Unleashed
is a definite absence; Entity holds an existing entity handle for a player, mob or boat, and Knot holds
fence coordinates. None means an entity that takes no lead, that the entity or holder is unavailable, that the
level is parked, or that a saved holder has not resolved; this read neither loads a level nor changes the lead.
EntityRef::leash_to and leash_to_knot take &mut dyn ServerHost and hand the lead to a player, mob, boat or
fence as Leashable.setLeashedTo does: the entity lets go of any other holder and gets off its vehicle, and neither
canBeLeashed nor the distance is asked, so a holder too far away snaps the lead on the next tick. detach_leash
drops the lead as an item or removes it; a mob's removal fires entity_unleash.
Through MobRef: no_ai/set_no_ai (Mob.isNoAi and setNoAi: the mob stands, saved as NoAI) and
set_baby (Mob.setBaby through the canonical species-specific setup). Ageable mobs start at their species'
baby age (-48000 for a sniffer, otherwise -24000) or become adults at 0, including the age-boundary
attribute, navigation and AI changes. Their age is saved as Age where the kind saves one. Non-ageable babies,
such as zombies and piglins, keep their non-growing baby state instead. A species with no baby form refuses
set_baby without changing anything. Neither set_baby nor set_no_ai changes PersistenceRequired.
The persistence pair persistence_required/set_persistence_required reads and sets Mob.isPersistenceRequired;
vanilla gives that setter no way back. requires_custom_persistence is a separate despawn predicate
(Mob.requiresCustomPersistence and its species overrides): passengers, leashes and species-specific reasons
such as an enderman carrying a block. It is independent of PersistenceRequired and NoAI, not an alias for
either flag. The separately modeled dragon has no such predicate and answers None to this query, even though
its persistence flag can be read and set. A /summon and a spawn egg set their flags before the entity-join hook
runs, so a mod's own write in that hook is what stays.
Riding topology and resident handles
EntityRef names only an entity resident in the current level. A rejected Mob
passenger can remain a full object in a riding tree without becoming resident;
its saved UUID does not make another object's handle refer to it.
is_passenger and is_vehicle describe the actual relationships, including
retained off-level objects. Thus is_passenger can be Some(true) while
vehicle is None, and is_vehicle can be Some(true) while passengers
returns an empty list. Dying passengers keep their place until removed.
passengers returns only resident direct passengers in boarding order.
indirect_passengers walks the full tree depth-first, through retained objects,
and omits only unrepresentable handles: a resident grandchild below an off-level
passenger still appears. root_vehicle also traverses retained intermediaries,
but returns None if the actual root is off-level; it never returns a resident
descendant in its place. is_passenger_of_same_vehicle compares the actual roots,
so two resident entities can share an off-level root even though neither can
obtain its handle. Cyclic relationships have no root and return None.
controlling_passenger chooses the actual first passenger before checking whether
its handle can be returned. An off-level first passenger means None, not a later
resident rider. All these reads refuse stale input handles; none loads a retained
object into the level or exposes a handle for it.
During actual boat or cart destruction, Mob dismount notification still runs, but remaining edges are forcibly unlinked even when a listener cancels. This safe interim prevents references to dropped vehicles and preserves each Mob's resident or off-level classification and the frozen state of descendants. Retaining removed vehicles to preserve canceled relationships is deferred to #2446. Cancellation while the vehicle remains live, including underwater or activator ejection, still works.
Client effect interpolation
LivingRef::effect_blend_factor(host, effect, partial) reads the local player's client effect blend.
A newly blended ordinary effect reads 0 before its first effect tick, then interpolates to its
post-countdown target each client tick: 1 while active, 0 once expired. A finite effect that expires
on its first tick never rises; an infinite effect still has the initial interpolation.
An unblended addition starts at its target immediately; replacing an existing effect keeps its blend.
Darkness and nausea use their existing longer fades. Pass the render partial tick unchanged: the read
does not clamp it. A missing effect reads Some(0.0); an unknown effect, stale handle or entity other
than the local player reads None. Other visible entities' effect blends are not yet supported.
The build's warnings
Before it builds, the launcher validates injection declarations and reports their slot/source routes
in native/build.log. Invalid or stale declarations stop the build; see Injections.
A mod's own ingredient kinds, matching at craft time
The load-time list only names items. Matching a concrete stack — its components, usually —
uses Hooks::ingredient_test(context, event): the ingredient's test(host, stack) reaches it with the loop context of the side host runs on, the
kind and NBT compound the load-time hook saw, the stack being matched and the verdict, already true
(the list named the item). Set accept to false to refuse the stack (ICustomIngredient.test).
The hook may be called many times a craft; the answer must be the same for the same stack.
data is an API Compound, not a JSON object. Match its fields as typed Tag
variants (Int, Long, List, Compound, and so on); the game converts the
recipe metadata once at load with vanilla JsonOps-to-NbtOps rules. Numeric JSON
arrays remain lists. Nested null refuses the ingredient and fails the recipe
like an unclaimed kind; matching retains the same compound without reconversion.
For an end-to-end mod test, use horizon-machine-sample
and implement Hooks::ingredient / Hooks::ingredient_test with the typed
compound contract; no JSON signature is needed in a mod.
The mod's crate
The root Cargo.toml and src/ form the mod's crate. It depends on horizon-mod-api (lib
mod_api), never directly on the game. The launcher supplies the pinned SDK and declared
mod dependencies.
Registry dependencies belong to the mod. A mod may use crates.io packages that the game does
not use, or different versions of packages the game uses. Each package must have an exact version
and SHA-256 checksum in the mod's registry dependency lock, stored as Cargo.lock beside its
Cargo.toml. Keep this file in version control and include it in the mod package. Changing it
changes the native build's cache identity.
Generate it explicitly after choosing your registry dependencies:
horizon-launcher native-lock path/to/mod
# To deliberately resolve fresh pins and replace an existing lock:
horizon-launcher native-lock path/to/mod --update-lock
This is the registry dependency lock consumed by launcher staging, not a promise that ordinary
Cargo can resolve the original pinned SDK declaration offline. The command creates an inert copy
of the manifests, removes only SDK dependencies naming the exact pinned Horizon repository and
revision, and resolves registry metadata. It does not copy or execute mod sources, build scripts
or proc macros. It does not modify the original manifests or Cargo configuration. Existing pins
are not replaced without --update-lock; a refused input leaves the existing lock untouched.
A starter that uses only the launcher-owned SDK does not need registry pins until it adds a
registry dependency.
Builds consume an existing lock; they never silently generate it or repin a missing dependency. The launcher may resolve registry index metadata online using an inert manifest whose requirements are exact pins. Before fetching crate archives, every resulting identity and checksum must match a supplied lock. Cargo checks downloaded archives against those checksums. Only after this fetch does the launcher resolve the actual staged workspace offline.
The game's locked packages, checksums and dependency graph stay immutable. A different mod-owned version may coexist where Cargo permits it. If a requested version would replace a game version, the build refuses it rather than updating the game. A native library with conflicting providers cannot be silently substituted. Feature selections must not introduce unpinned packages or change the game's graph.
Git dependencies other than the proven launcher-owned SDK declaration, alternate registries,
paths escaping the mod, linked dependency paths, [patch], [replace], and mod-owned Cargo source
configuration are refused. Local crates inside the mod are traversed without copying their code
into the registry resolver. Registry dependencies remain native code: their build scripts and
proc macros execute during compilation, and the resulting mod runs with your rights. Trust their
authors as well as the mod's author.
Before compiling these dependencies, the launcher shows how many registry crates the mod adds and names the reachable build scripts and proc macros. Declining stops before any mod dependency is compiled. The existing native-code permission is also required; downloading a checksummed crate does not make its code trusted.
On the optimized prebuilt route, Cargo compiles the mod's registry graph and the final wrapper against immutable SDK binaries. Registry dependencies never cause an automatic source rebuild. The SDK must include its locked graph and runtime native-library inventory; older SDKs without that information refuse registry builds. The launcher preserves the compiler/cache wrapper chain and uses its pinned Rust compiler.
SDK crate identities are not interchangeable. A registry-built copy of a trait or type is not
the SDK's copy, even at the same version. For example, passing an SDK value implementing the SDK's
serde::Serialize to a registry-built serde_json can fail with Rust E0277. The launcher reports
that boundary; it does not replace either library or rebuild the game to hide it. Keep private
registry types behind your mod's API-facing values.
Runtime native libraries emitted by registry builds are collected only from managed build and Cargo directories. Host-only build tools and proc macros are not packaged into the game. Conflicting native providers or output filenames, escaping outputs, relative native search paths, and bundled framework outputs are refused explicitly. System libraries remain system dependencies. These restrictions do not trigger a source fallback.
Conditional data
Server JSON files can carry a neoforge:conditions array. Supported readers are
recipes, loot tables, item modifiers, predicates, enchantments, jukebox songs,
instruments, damage types, advancements, synchronized registry JSON, world-table
tags (including loot, damage, synchronized registry and function tags), and native item/block data maps.
Every condition must hold; a false or unsupported condition omits the file.
Ordinary JSON resources select the winning pack file first, then evaluate its
conditions before decoding the data. If the winner is omitted, a lower pack's
version does not return. Tags and data maps evaluate each layer before merging,
replace, or remove, so an omitted layer leaves lower entries intact. A damage
registry is frozen at world open; reloading reevaluates its tags, not its type
definitions. Generated item default components retain their existing prototype
lifetime and are not conditional data files.
neoforge:mod_loaded asks the owning resource generation's captured enabled
server-mod IDs, including active code-only native and WebAssembly mods, plus
minecraft always. Client-only, disabled and refused packages are absent;
resource namespaces are not mod IDs. Other supported forms are neoforge:true,
neoforge:not (value), and neoforge:and / neoforge:or (values). An unknown
kind anywhere in these trees omits the file, including beneath not or or.
A file without the conditions array loads normally. Successful reloads publish
changed enabled IDs together with the data generation, including command predicates
and item modifiers; retained readers keep
their old snapshot. Recipe selection and its condition evaluator are unchanged.
Reading recipe rules
recipes_of_type returns the loaded recipes after pack overrides. Shaped,
shapeless and cooking recipes expose their resolved ingredients and fixed
results. Stonecutting, smithing and special crafting recipes expose a typed
RecipeRule instead, including resolved ingredient roles. A smithing or
special rule's result depends on its inputs: its template is not a fixed
stack. Use LevelRef::recipe_for for the actual output and remainders of a
crafting grid. Smithing transform's template and addition ingredients can be
absent, requiring empty slots. Trim patterns are either registry references or
inline definitions. Result templates retain their count and component patch;
stonecutting exposes the complete fixed output. A mod-defined recipe type
retains its original json. Look up recipes again after a data reload.
Recipe filters
A content mod or a data pack can remove recipes, or swap one of their inputs or outputs, without knowing
their ids: a file data/<ns>/recipe_filter/<name>.json holds {"filters": [...]}. Each filter has match
keys, all of which must hold (a filter with none matches every recipe), and exactly one action:
| Key | Meaning |
|---|---|
id | the recipe id; * matches any run of characters (minecraft:*_planks) |
type | the recipe's type, with the same * (mymod:*) |
input | an item id or #tag among the recipe's inputs |
output | an item id among its outputs (anything under result, results, output or outputs) |
remove | true: the recipe is not loaded |
replace_input | {"from": ..., "to": ...}: every input that is from becomes to |
replace_output | the same, for the outputs |
{"filters": [
{"id": "minecraft:*_planks", "remove": true},
{"type": "mymod:*", "replace_input": {"from": "minecraft:iron_ingot", "to": "#c:ingots/iron"}}
]}
Filters run on each recipe after the packs are merged and its load conditions held, so they see the final
set and reach every recipe type, mods' own included: they work on the recipe's JSON, every string outside
type, group, category and the conditions (a {"tag": ...} object's tag reads as #tag). Every recipe
loader applies them: crafting, the stations, brewing, the recipe book and what players are sent. Files apply
in id order and each file's filters in order; a pack's file replaces a file of the same id, as any data file
does. A filter with an unknown key or not exactly one action is skipped with a warning, so a typo never
matches every recipe.
A mod's own enchantment effect kinds
A minecraft:post_attack entry with enchanted attacker and affected victim whose effect.type is in a
namespace outside minecraft: reaches
native_enchant_effect(enchantment, kind, data, level, victim) when a player's weapon hits a mob and the entry's
requirements pass. victim is the mob's id, and the acting player is the one holding the weapon. A bare type
name is minecraft's own. Other components and pairs, a hit on a player, and an entry inside minecraft:all_of
aren't told yet. An unclaimed kind does nothing. event.data borrows a Tag retained at definition load, not JSON; invalid payloads refuse the definition.
A mod's own consume effects
A consumable's on_consume_effects entry of a type in a namespace of its own reaches native_consume_effect(kind, data) when the item is consumed, on the server
thread, for the acting player. The kind and a borrowed Tag converted from the definition at load are there; the game's own effects,
sounds, teleports and drops are open to the hook. An unclaimed kind does nothing.
Consumable and death protection payloads are decoded once for item definitions and stack component patches; the event borrows the retained payload. Invalid payloads refuse definition loading rather than failing when consumed.
A mod's own loot functions and conditions
A loot table's function or condition that names a type in a namespace outside minecraft: reaches native_loot_function(kind, data, ctx, stack) or
native_loot_condition(kind, data, ctx, holds) when the table rolls. The function changes the stack; the
condition says whether it holds. The context brings the entity, the damage, the player who killed it, the
tool, the block, the luck, the biome and a seed from the roll's random. An unclaimed function leaves the
stack, an unclaimed condition fails, as unknown kinds do; a bare type name is minecraft's own.
The tables the items engine rolls (block drops, entity drops, loot_table, interact_loot and the
built-in tables) answer both hooks, with the tool and the block in the context. The function hook there
gets an owned API LootStack: its resolved ItemKey, signed intermediate count, damage, enchantments
(a book's stored ones), potion, ominous bottle amplifier, stew effects and ordered NBT component patches.
Each patch is (String, Option<Tag>); None removes a component, and repeated IDs apply in order.
Unknown NBT values retain their exact type, including byte arrays and bytes. Names are API Text trees.
The context is an owned LootContext, including entity equipment and deferred exploration-map facts.
Conversion runs only after the subscription guard. Writeback validates the entire candidate before replacing
the engine roll; an invalid candidate leaves the roll unchanged. Intermediate counts can be non-positive:
these values are not occupied inventory stacks. A function that asks for a map's structure search (map/exploration) changes nothing there: the
items engine has no level to search. In the items engine's context the entity, damage, player and
biome fields are None and luck is 0; in the main loot engine's context the tool and block fields
are None.
Both engines' contexts carry place, where the roll happens: origin (a broken block's centre, a dead mob's
position), block_entity (the broken block's entity as it was before the break, saved with its id, position and
Items) and level (the dimension, the game time, the time of day, the rain and the thunder, for conditions like
vanilla's time_check and weather_check). The game sets it for a block a player or an explosion breaks and for a
mob's death loot; on other rolls place is None.
Recipe book category declarations (first slice of #5501)
The recipe_book_categories hook declares recipe book categories. Trait mods import
RecipeBookCategory from mod_api and append declarations in
Hooks::recipe_book_categories:
event.categories.push(RecipeBookCategory {
id: "mymod:crushing".into(),
recipe_type: "mymod:crusher".into(),
icon: "mymod:crusher".into(),
});
Mixin bundles use native_recipe_book_categories(categories) at the same hook
point. host.recipe_book_category("mymod:crusher") (the mixin helper is
recipe_book_category) returns the validated declaration, including its
icon, or None. It does not grant recipes. Declarations are requested lazily at
first lookup, cached for that recipe table, and invalidated on a new session,
server recipe-content replacement or server /reload. No lookup means no hook
call or per-frame work. A client validates against its own custom recipes unless
native_datapack_sync has supplied the server's type.
Every field must be a fully namespaced resource id. Category ids and recipe types
must be outside minecraft:; the type must occur in the current custom recipe
table. The icon must name a registered, non-air item. Invalid ids, unknown types,
missing/unknown icons, and duplicate category ids or recipe types are logged and
skipped, never panicked on. The first valid declaration wins each id and type;
an invalid declaration does not reserve either. This slice supports one category
per type, not per-recipe category selectors.
Not yet book UI support: custom recipes still have no book displays or unlock/display-id synchronization. This slice deliberately leaves vanilla tabs, search, layouts and unlock behavior unchanged. Adding custom tabs, displaying and searching recipes there, and unlocking them like vanilla remain follow-up work for #5501; a declaration alone will not make a recipe visible.
For proposed book identities and saved open/filter flags, see the custom recipe-book settings design.
A mod's own ingredient kinds
A recipe's ingredient that names a type in a namespace outside minecraft: reaches
Hooks::ingredient(context, event) at load time. Fill event.items with the item ids the
ingredient matches (item ids only: components are not checked; a match-time hook follows
separately). Duplicates are removed keeping first-seen order. The hook gets the DataLoadContext of the load that parses the recipes and hears each occurrence of the kind once per load, nested ones and ones under any slot a recipe reads an ingredient from included, before the load's tables parse the recipes: the client's own recipe load has origin() ClientPreload, the server's ServerDataLoad. A server loads its recipes once however many tables and copies read them (its recipe tables, the stations and the copy synchronised to guests, on a dedicated server too), and a /reload asks again once; a client that only joins a remote server is never told ServerDataLoad. The answer must be the same every time. An unclaimed kind leaves the list empty and the recipe fails, as an unknown kind does.
data is an API Compound, not a JSON object. Match its fields as typed Tag
variants (Int, Long, List, Compound, and so on); the game converts the
recipe metadata once at load with vanilla JsonOps-to-NbtOps rules. Numeric JSON
arrays remain lists. Nested null refuses the ingredient and fails the recipe
like an unclaimed kind; matching retains the same compound without reconversion.
For an end-to-end mod test, use horizon-machine-sample
and implement Hooks::ingredient / Hooks::ingredient_test with the typed
compound contract; no JSON signature is needed in a mod.
Structure types
A structure type is a mod's code where a data pack names one: a structure JSON whose "type" is
"mymod:tower" instead of a vanilla type generates by the mod's StructureType (vanilla's
StructureType, whose find_generation_point returns the start and whose pieces are
StructurePieces, loaded by the StructurePieceType that load_piece is — NeoForge registers
these in the registries STRUCTURE_TYPE and STRUCTURE_PIECE). Register it once, at startup, in
Hooks::register; the id is in the mod's namespace, and a duplicate id, an id in another mod's
namespace or an empty name stops the game at startup with a message naming the mod:
use mod_api::{
BlockKey, BlockMetadata, BlockState, BoundingBox, Compound, FoundStructure,
GenerationContext, Hooks, PieceContext, Registrar, RegistrationContext, StateConstraint,
StructureError, StructurePiece, StructureType, Tag,
};
use std::sync::Arc;
const MATERIAL: &str = "minecraft:oak_log";
struct Tower {
material: BlockKey,
}
#[derive(Debug)]
struct Pillar {
min: [i32; 3],
max: [i32; 3],
state: BlockState,
axis: String,
}
impl StructureType for Tower {
fn find_generation_point(
&self,
context: &mut dyn GenerationContext,
) -> Option<FoundStructure> {
let state = BlockState::of(
context.metadata(),
self.material,
&[StateConstraint::new("axis", "y")],
)
.ok()?;
let min = [
context.chunk().x * 16,
context.sea_level(),
context.chunk().z * 16,
];
Some(FoundStructure {
position: min,
pieces: vec![Arc::new(Pillar {
min,
max: [min[0], min[1] + 4, min[2]],
state,
axis: "y".into(),
})],
})
}
fn load_piece(
&self,
metadata: &dyn BlockMetadata,
tag: &Compound,
) -> Result<Arc<dyn StructurePiece>, StructureError> {
let block = saved_text(tag, "block")?;
if metadata
.name(self.material)
.map_err(|error| StructureError(format!("saved material: {error:?}")))?
!= block
{
return Err(StructureError(
"saved block does not match this pillar's material".into(),
));
}
let axis = saved_text(tag, "axis")?;
let state = BlockState::of(
metadata,
self.material,
&[StateConstraint::new("axis", &axis)],
)
.map_err(|error| StructureError(format!("saved block properties: {error:?}")))?;
Ok(Arc::new(Pillar {
min: saved_position(tag, "min")?,
max: saved_position(tag, "max")?,
state,
axis,
}))
}
}
impl StructurePiece for Pillar {
fn bounding_box(&self) -> BoundingBox {
BoundingBox::new(self.min, self.max)
}
fn post_process(&self, context: &mut dyn PieceContext) -> Result<(), StructureError> {
for y in self.min[1]..=self.max[1] {
context.set_block([self.min[0], y, self.min[2]], self.state);
}
Ok(())
}
fn add_additional_save_data(&self, tag: &mut Compound) {
tag.insert("min".into(), Tag::IntArray(self.min.to_vec()));
tag.insert("max".into(), Tag::IntArray(self.max.to_vec()));
tag.insert("block".into(), Tag::String(MATERIAL.into()));
tag.insert("axis".into(), Tag::String(self.axis.as_str().into()));
}
}
fn saved_text(tag: &Compound, key: &str) -> Result<String, StructureError> {
match tag.get(&key.into()) {
Some(Tag::String(value)) => value
.to_string()
.map_err(|error| StructureError(error.to_string())),
_ => Err(StructureError(format!("missing or invalid saved {key}"))),
}
}
fn saved_position(tag: &Compound, key: &str) -> Result<[i32; 3], StructureError> {
match tag.get(&key.into()) {
Some(Tag::IntArray(values)) if values.len() == 3 => {
Ok([values[0], values[1], values[2]])
}
_ => Err(StructureError(format!("missing or invalid saved {key}"))),
}
}
#[derive(Default)]
pub struct StructureMod;
#[mod_api::horizon_mod]
impl Hooks for StructureMod {
fn register(&self, registrar: &mut dyn Registrar, _: &RegistrationContext) {
let material = registrar.block_key(MATERIAL);
registrar.register_structure_type("tower", Tower { material });
}
}
There is no Host in world generation: starts build on worker threads and pieces place per chunk,
so the type and its pieces see only two small contexts, Send + Sync and deterministic. The same
seed gives the same pieces, and a type keeps no mutable state, because two workers can build
neighbouring chunks at once.
StructureType::find_generation_point(&self, context: &mut dyn GenerationContext) answers
FoundStructure { position, pieces } (each piece an Arc<dyn StructurePiece>) or None for "no start in this chunk". The
GenerationContext is vanilla's GenerationContext over one chunk: the seed, the chunk, the
random seeded as vanilla seeds it for the chunk (setLargeFeatureSeed), the height and
base-column queries, opaque noise-biome identities and the valid-biome test, borrowed immutable
metadata(), and the structure's config as a shared Arc<Tag> (context.config()). JSON converts
once when the definition loads or reloads, not once per start. Nested nulls refuse at load. Both contexts are traits and the random is a
StructureRandom (next_int_bound, next_long, next_float, next_double, next_boolean),
so a type built to mod.wasm runs the same code. Generated worker calls take the dependency
group's store lock and per-call fuel or epoch budget; a trap stops that group with a typed refusal. Everything around the type stays the game's: the structure set's
spacing, the structure's biomes and terrain_adaptation are placed by the engine, exactly as
for a fortress.
Each piece is a StructurePiece (Debug + Send + Sync): bounding_box, post_process and
add_additional_save_data are required. After all pieces of the start were placed, the type's own
after_place (vanilla's Structure.afterPlace) runs once per chunk with every piece's current
box; it defaults to doing nothing. The
PieceContext of post_process and after_place is the chunk being placed: set_block writes a
block and drops a position outside the writable box silently, as vanilla's
StructurePiece.placeBlock skips it; block and height read, state/state_with resolve a
a registered BlockKey into an API-owned BlockState, random is the piece's RandomSource as a
StructureRandom, and metadata().name(state.block()) borrows its canonical block name.
solid_render asks whether
a state draws as a solid block, and entity queues an entity from its saved NBT — its id, Pos
and fields — which the generating chunk holds and the game finishes as a structure mob's
(Mob.finalizeSpawn with STRUCTURE when the NBT keeps the "horizon:finalize" byte a structure
template's entities carry). block_entity places a block entity from its NBT.
Saving goes through the engine's StructurePiece.createTag — the type id, BB, O, GD —
around the piece's add_additional_save_data. When the chunk loads back, the type's load_piece
(the StructurePieceType loader) rebuilds the piece exactly as it was saved; there is no
regeneration from the seed. load_piece(metadata, tag) returns a typed StructureError for invalid
saved block names, properties or piece data. Its metadata is the same retained snapshot the worker
contexts borrow. Resolve states once in piece construction or load, as the example does; keep
canonical names and properties in saved data, never process-local ordinals. post_process then
writes retained states without per-block name lookups or allocation.
Open question — the saved piece's id is the structure type's, not a piece type's. Every piece
of a type saves with that type's id, the STRUCTURE_TYPE id; Horizon has no separate
STRUCTURE_PIECE registry, so there is no per-piece-kind id in the tag. A mod whose type has
several kinds of pieces tells them apart in load_piece by its own saved data (a tag field its
add_additional_save_data writes). The O (orientation) and GD (genDepth) fields are written
as -1 and 0 — the adapter has no orientation or depth to record — so a piece must not read
them back as meaningful.
The pieces ride the one piece mechanism the game has: they are legacy pieces behind an adapter, so
the start cache, the cross-chunk placement, the beardifier and /locate structure treat a tower
piece like a fortress piece's. StructureRegistry reads the registered types before it falls to
Unsupported, so a data pack structure whose "type" no installed mod registers places nothing,
exactly as before.
Fluid ingredients
A mod's own recipe type that drinks from a tank reads its fluid inputs as a FluidIngredient, the
fluid analogue of an item ingredient: FluidIngredient::from_json(json, context) parses a fluid
("minecraft:water"), a #tag (the fluid tags the load's DataLoadContext holds), a list of
them, or NeoForge's compound, intersection and difference shapes, with the same walk the item
ingredient parses its own JSON with — call it where a data load hook hands you the context, as the
ingredient hook does while the game resolves recipes. test(&FluidStack) asks whether a tank's
fluid answers, and fluids() lists the fluids it accepts, in tag order, for a recipe book. A kind
outside the standard shapes fails the parse: fluids have no custom ingredient hook, and components
are not checked — any stack of a named fluid answers.
SizedFluidIngredient adds the amount, as NeoForge's SizedFluidIngredient:
{"ingredient": <fluid ingredient>, "amount": 500} in millibuckets, "amount" defaulting to a
bucket (1000) and always positive. Its test(&FluidStack) asks that the stack matches the
ingredient and carries at least that much.
Data files
A mod has one top-level data/ tree and one assets/ tree, with the same namespace and
content checks as a content mod. They are normal pack resources, not a second native-only data folder.
Server data reload listeners register through
Hooks::add_server_reload_listeners. Their ServerReloadContext lists namespaced IDs and reads the
winning bytes from selected datapacks and the mod's own data/. Parse once in prepare and publish in
apply, not on the tick. Server listener applies run before data_pack_files. A later refusal
keeps the old engine tables and Parts, but cannot undo effects of listeners or hooks that already
ran. Contributions share a list, so a failed file cannot be attributed to a particular mod.
The original pack trees remain separate from the launcher's code-only horizon-mixins input.
Client-side asset reads use client asset reload listeners; server
listeners cannot read assets/. Packaged assets also participate in the game's normal resource
packs.
Outside a listener, host.resource(pack_type, &identifier) reads one file from the selected
stack: PackType::ServerData takes mymod:machines/furnace.json from data/, and
PackType::ClientResources takes mymod:models/block/furnace.json from assets/. As with
ResourceManager.getResource, the highest selected pack that supplies the file wins; filters,
a world's datapacks and a mod's own root data all apply. The answer is the winning bytes, or
None for a missing, unreadable or over-64-MiB file. Use a reload listener for a whole folder
or for parsing at reload time; a single read does not reload packs. Directory-pack
symlinks follow vanilla pack behavior: linked resource files are read through the pack.
Code-only bytes belong inside the crate: use Cargo's ordinary build.rs to generate a table in
OUT_DIR, then compile it with include_bytes!(concat!(env!("OUT_DIR"), "/table.bin")).
They are not runtime pack resources and do not participate in resource reloads.
What a native bundle holds. The one manifest, optional icon, root Cargo.toml and build.rs,
.rs and .toml files under src/, assets/ and data/, and root text files (README*, LICENSE*,
LICENCE*, COPYING*, NOTICE*). Source and resource size limits are checked separately;
anything else refuses the archive (the manifest).
One .hmod for content and code. Without root Cargo.toml, nothing compiles. With it and [native],
the launcher builds the code and the game loads the same package's content through horizon_mod_package::Content.
Both kinds use horizon-mod.toml for their identity. Executable mod.wasm files are unsupported.
What the launcher does
Developing a mod? Pass --profile developer to horizon-launcher native-build.
The choice is remembered for that instance, not shared with other instances.
Developer builds the game crate unoptimized for iteration rather than game performance. Use it while developing, not for playing. A needed build picker starts at
Balanced until a choice is saved; automatic prebuilt builds still use the release profile.
In the window: install a native mod from the Mods page, into the instance picked on the left ("Installs into
{instance}", shown even with one instance). The first one shows a trust notice once (native mods aren't sandboxed;
a build may download Rust and other build tools); mods install prints it too. Each
installed native mod's row says "Installed in {instance}" and has Open folder (<instance>/horizon-native-mods/).
The installed list has a Native build of {instance} row for the instance picked on the left, never another's:
- A badge: Not built (the version it plays isn't a
native-*build), Needs rebuild (themodsin itsnative-mods.jsondiffer from those turned on, by id and version) or Up to date, with Build for the first two; - For a native build, "Plays native-…, built on Horizon {version}" (the
baseof itsnative-mods.json);
The instance list on the left shows such an instance as "Native build · Nightly 10-28" (its base). The launcher names
the mods, warns in red, and builds only from "I trust these mods: build"; the row shows each step and the error if
one fails, on the instance being built only; one build runs at a time. From the command line:
horizon-launcher native-build [FOLDER] [--instance NAME] [--update] --trust-native-code. FOLDER is a bundle, an
unpacked .hmod, or a folder of them; without it, the native mods turned on in the instance. It is not an MCP tool:
trusting native code is the player's decision, never an agent's.
-
Rust. The launcher's own toolchain (
rust_toolchain): Rust 1.99.1's officialrustc,cargoandrust-std(andrust-mingwon Windows), each pinned by SHA-256, downloaded once from static.rust-lang.org into<launcher data>/rust/, with its own Cargo home<launcher data>/cargo/. No rustup, nothing on the system; the player's own Rust is never used. It ships for macOS (Apple Silicon and Intel), Linux x86-64 and Windows x64. Windows uses thewindows-gnuhost, which links without Visual Studio.rust-mingwonly links, so on Windows the toolchain also brings MinGW-w64 (rust_toolchain::MINGW: WinLibs' GCC 16.2.0 on MinGW-w64 14.0.0, MSVCRT likerust-mingw, one zip pinned by SHA-256, itsmingw64/without the documentation) on the build'sPATH:ringandhidapicompile C, andwindows-sys'raw-dylibimports needdlltooland the assembler it runs. The linker is the system's on macOS (the Command Line Tools: the launcher asks forxcode-select --install) and Linux (cc). On Linux the game also builds against the system's ALSA (alsa-sys) and libudev (libudev-sys,hidapi), found withpkg-config, whose development files desktop distributions don't install. Before downloading anything the launcher checks forcc, the C library's headers (cccompiles an include ofstdint.h),pkg-config,alsaandlibudevand names what is missing, with the install command for the distribution in/etc/os-release(Debian, Ubuntu and Mint:sudo apt install build-essential pkg-config libasound2-dev libudev-dev). SteamOS has GCC without the C library's headers, and a read-only system: native builds don't work there yet. On Windows, Smart App Control can block newly compiled build scripts or toolchain programs withos error 4551. The launcher adds advice when it sees that error; it does not check the setting before a build. To build after a block, Windows Security > App & browser control > Smart App Control settings offers an off switch. On some Windows versions, turning it back on requires reinstalling Windows. Content mods need no build and work with Smart App Control on.horizon-launcher rust status|install|remove. -
horizon-mixins, at the exact commit the mods name (
[native] mixinsinhorizon-mod.toml), built once with that cargo (cargo install --locked --git … --rev …) intonative/tools/<commit>/. -
The source of the pinned commit: GitHub's archive of it, once per commit (
native/source/<commit>/, about 63 MB unpacked), with the commit in.horizon-revision. Never the player's own checkout. Links in the archive are left out: the tool refuses them and nothing needs them to build. -
Stage. The projects are copied first, so the build and its key use that copy whatever happens to the folder meanwhile. Compiled slot listeners register without a source splice. For source-routed internals declarations the launcher generates private slots and source adapters before compilation. It stages the mod crates and generated executable, copying only changed files into
native/stage/, so Cargo, which goes by file times, recompiles only the crates the mods touch. Each file written gets the current time, never the archive's (the commit's) or the copy's original: on Windows and macOS a copy keeps it, and a file older than the last build reads as already compiled, so switching Nightlies linked the previous one's crates (horizon-rust/horizon#10421). A stage from before that fix is written again once (native/stage.stampedmarks it). Registry dependencies are checked against Horizon'sCargo.lockbefore Cargo resolves the staged workspace (The mod's crate). -
Compile:
cargo build --locked --profile <fast|release> [--config ...] [--features horizon-client/mods] -p horizon-native-game, intonative/target/, with one of three build modes:Mode Cargo profile Use optimizedrelease, the release's own (game-build.yml: thin LTO, one codegen unit)The mode for playing: mods that need nothing but the mod API compile against the prebuilt game, built with this profile; any other set builds the whole game from source with it balanced(initial picker choice)fast, the workspace's own:codegen-units = 16, incremental, andlto = false, Cargo's default, which is thin-local LTO across one crate's codegen units, none across cratesAlways builds the game on this computer: a rebuild recompiles and links only what changed, and the game plays a little slower than Optimized developerfastwithhorizon-clientat opt-level 0 (--config profile.fast.package.horizon-client.opt-level=0)Developing a mod: the quickest rebuild after an edit, but the game runs about twice as slow, so iterate with it rather than play Only Optimized uses the prebuilt game. The Mods page shows how each native mod reaches the game: a bolt for the prebuilt game, a hammer for a full game build (its menu names the reason), a check when the game already has the mod. A build under way shows its stage and can be cancelled.
devlowers onlyhorizon-client, so an edit in another workspace crate (horizon-client-lib,-render,-world) still compiles at opt-level 3, as with Balanced. The launcher sets it with--config(native_mods::Profile), so the workspace's own[profile.fast]builds stay as they are. The Mods page's "Build mode" card ornative-build --profile ...picks and remembers the instance's choice. Without a source-build reason or explicit CLI choice, installed mods use the automatic prebuilt route; a saved preference is applied only when a picker is needed.native-mods.jsonrecords the effective profile.Each profile is in the build's key, so their builds never mix and may all stay.
balancedanddevelopersharenative/target/fast/: every crate buthorizon-clientis built with the same settings, so switching between them recompiles onlyhorizon-client, and Cargo keeps both builds of it, so switching back finds the other still there.optimizedhas its own folder (native/target/release/). The first build in a folder builds every crate once; later builds reuse them. Cargo downloads the locked crates from crates.io (Horizon has no git dependencies). The compiler and the horizon-mixins build run at the lowest priority (rust_toolchain::low_priority: nice 19 and the idle I/O class on Linux, nice 19 on macOS, below-normal priority on Windows), so the desktop and a game in play stay smooth; on an otherwise idle computer they still use every core. The log isnative/build.log; a failure shows its last lines (a horizon-mixins diagnostic in one line). -
Install: a new version,
native-<commit>-<key>, a copy of the starting version's folder (resources included, cloned on APFS, Btrfs and ReFS) with the built game in place of its own, and anative-mods.jsonsaying which release or Nightly it started from (base) and which commit, mods, horizon-mixins and Rust made it. Canonical mod projects are copied intohorizon-native-mods/<id>/beside the executable. The game lists its embedded inventory in a crash report's details (Native Mods: id version, …; they stay in the local report). The instance the mods are installed in plays it, the default Horizon instance included: the build pins its version, as picking a version does. Updates of the launcher's version leave a pinned instance where it is, and their clean-up never removes a native build. The Native mods instance keeps its worlds and its build.
Build log
The full log of a build is native/build.log, beside the build's own folders under the launcher's data
folder (native_mods::log_path). Each new attempt replaces it. Compiler output and the final result
stay in the log, including failures before compilation or while selecting or exporting the built game.
The launcher has a Build log action at the top right. It is there whenever a log exists, also right after the launcher starts, and it always opens the latest build's log: the next build replaces it. The panel shows a bounded preview of the end of the log and says when it truncated; Copy log copies the whole file, Open full log opens it outside the launcher, Show in folder reveals it, and Refresh reads it again (also after a build finishes). The panel is a snapshot read on open and refresh, not a live stream: a running build's later output appears only on the next refresh. Nothing is uploaded; the log stays on the player's computer.
Rebuilds stay on the instance's Horizon
A build starts from the version the instance plays (native_mods::base_for). When that is a native build, the
next one compiles the same commit (its native-mods.json's horizon) from the same game files, unless a mod pins
another, so a rebuild after a mod changes or is switched off compiles only what changed. A newer Horizon is taken
only when the player asks: native-build --update starts from the newest installed release or Nightly (a full
rebuild); native_mods::update_for says which one, if any, the window can offer.
The compiler runs as the release's build does, whatever the player's own Rust setup: the launcher's toolchain
drops the variables that change compiler flags or the target (RUSTFLAGS, CARGO_ENCODED_RUSTFLAGS,
RUSTDOCFLAGS, CARGO_INCREMENTAL, CARGO_PROFILE_*, CARGO_BUILD_* but CARGO_BUILD_JOBS, CARGO_TARGET_*,
rustup's and the wrappers'), and starts Cargo from the disk's root with --manifest-path. Cargo reads .cargo/config.toml from
the folder it starts in and every one above it, whatever CARGO_HOME says, so from the launcher's data folder it
would read the player's ~/.cargo/config.toml, whose [profile], [build] and [target] settings beat the
workspace and the environment; no switch turns that search off. The source's own .cargo/config.toml, if it
has one, is passed with --config.
Every path the build's tools see — the toolchain, the Cargo home, native/ and the tools' temp dir (TMPDIR,
TMP and TEMP, where gcc, the assembler, ld and cargo install put their scratch files) — comes from
backend::build_dir: the launcher's data folder, resolved (never through a link, which horizon-mixins
refuses). On Windows, when that path is not spelled in ASCII — a Cyrillic user name in %APPDATA%, which
MinGW-w64's tools can't open — it is a real folder under ProgramData named for it (horizon-launcher-<id>),
and only when that can't be created the data folder's short (8.3) name. A short name alone is not enough
(#6009): rustc canonicalizes its own path to find its sysroot, which spells the long name again for the
linker. A data folder that is itself a junction to a drive with an ASCII path resolves to that and needs
neither.
Install from GitHub
An author can ship a native mod without a registry pull request per version: the player installs it from a
GitHub link, on the Mods page ("Add from GitHub") or with horizon-launcher mods add-github <url> [--mod ID | --all] [--instance NAME] --trust-native-code. The link is https://github.com/<owner>/<repo>
(its latest release), .../releases/tag/<tag>, .../tree/<ref> or .../commit/<sha>.
- Resolved to something exact. GitHub's public API (no sign-in; a rate limit is said plainly, and
HORIZON_GITHUB_TOKENraises it) turns the link into a release's.hmodfile or a commit SHA, never a branch that moves. A release with a.hmodasset installs that file (its SHA-256 is compared with the one GitHub lists). With no release or no such asset, the launcher fetchescodeload.github.com/<owner>/<repo>/tar.gz/<sha>and looks in it for mods by the rules ofnative-build <folder>: a flat project withhorizon-mod.toml,[native],Cargo.tomlandsrc/. Several mods: they are listed, and the player picks one or installs all. Each source folder pins the Horizon commit in[native] horizon; the launcher packs the checked project into a.hmod. Release packages must contain native code; content-only packages are rejected by this native installer. - Checked like the registry's files. The
.hmodis read ashorizon_mod_package::index::native::Bundle::readreads a registry file (flat source and content, size limits, nothing run) before it is installed. - Said first. Before anything is downloaded: "not reviewed by the Horizon registry; native mods run with full
access to this computer; read the source", with a link to the exact commit. The command needs
--trust-native-code, asnative-builddoes. It is not an MCP tool: trusting native code is the player's decision, never an agent's. - Origin kept.
horizon-native-mods/<id>.origin.jsonbeside the.hmod: owner/repo, release tag (for a release), commit, asset name and the SHA-256 of the installed file.mods listshows "from GitHub: owner/repo @ tag" (or the commit's first 7 digits). - Updates.
horizon-launcher mods update-github [ID] [--check]compares a mod from a release with the repository's latest release, and a mod from a commit with the head of the default branch; a newer one is installed with the same warning and--trust-native-code.mods remove <id>removes it. A mod already installed from the registry (or by hand) is never replaced.
Installing turns Native mods on, as any native mod does; then build as usual.
Cache and disk
The key hashes the commit, the horizon-mixins commit, Rust's version, the profile and features, and every file of
every bundle. The same set on the same commit is never built twice. A change gives a new build, and
native/target/ stays, so a rebuild compiles only horizon-client and what the mods touched.
Incremental rebuilds. With Balanced and Developer, Cargo recompiles only the changed parts of horizon-client, not the whole
crate with LTO. Patched source is cached separately by its mixin inputs and pins (the three most
recent snapshots are kept), so editing only a trait crate skips the mixins pass. This holds because
the stage (native/stage/) is one folder for every build and the launcher writes
only the files whose content changed (Cargo goes by file times), switching mods on the same commit reuses the same
native/target/, and the launcher removes CARGO_INCREMENTAL and CARGO_BUILD_INCREMENTAL from the build's
environment. Optimized has no incremental compilation (the release profile's own).
Build status. Before it starts, the build identifies its plan and profile: up to date, incremental rebuild,
first build, or prebuilt game. While compiling it shows the crate and crates done of the total, from Cargo's own
progress. Afterwards it identifies the completed profile and plan. Neither the window nor native-build promises
or displays a build duration. native/build.log retains Cargo's diagnostic output, without the progress bar.
Native client builds share the prepared resources of their base version instead of copying them into every build. The launcher passes that base folder to the game as --resources and retains the base while an installed native build references it. Remove those builds before removing their base. Dedicated server exports remain standalone and include their own resources.
Disk use varies with the platform, profile and set of mods. Run horizon-launcher storage to inspect the actual
cache rather than estimating its size from another computer's build.
Instance references and newest-build retention select which builds could be disposable; process scans never prove that a game cannot start using them. Current game builds take their own kernel-held runtime leases, including direct starts and Windows launches. Unix launcher starts also run through a holder. These claims survive the launcher exiting or being killed and remain held until the game process exits.
Cleanup classifies each unit separately. An installed version requires its own executable's current lease capability. Shared caches conservatively require that every potentially referencing local version and cached native build has that capability, including incomplete builds that an older executable could still start. A pre-holder build therefore keeps shared caches protected without preventing removal of an unrelated capable version. Kept space appears in Storage with its reason instead of being counted as freed.
Deletion reacquires the unit exclusively and rechecks the capability decision. Executable publication (installation, import, prebuilt extraction and compilation) shares a registry publication lock; cleanup holds it exclusively from its capability recheck through detachment, so an older build cannot land between the scan and the rename. The central storage LRU replaces separate native spare-build quotas. Installation completion markers are removed first. Markerless cache and resource units are renamed away on the same volume before their detached trees are deleted, so a late start sees either the complete unit or no unit, never a partially cleared directory. A late game that finds cleanup's exclusive lease, missing registration, missing resources or an incomplete install refuses startup with a clear message rather than opening half-deleted files. Start the launcher once to register an older installation before starting its executable directly.
Rust/Cargo/temp build-tool caches retain their Cargo-lock cleanup rules. native/target/ and its profiles remain
residual even for current builds: their in-place Cargo cleanup preserves lock inode ancestry, but they still need
a completion marker or a retire-executables-first protocol before automatic or manual eviction can be safe.
Units containing operational Cargo lock files are likewise retained.
Private launch records and raw stdout/stderr capture files are collected as one group only after every protocol owner exits, under an exclusive namespace gate. Malformed records and unknown files remain untouched. Record collection retains its 4,096-entry trigger. The permanent gate and published live-unit keys are not collected; deleting a live key would break direct startup's noncreating contract. Cleanup retires a deleted unit's own key under its exclusive lease. Units deleted outside the launcher can leave empty registry files behind.
Trust
- A notice, then a warning. The first native install shows a trust notice once. The command needs
--trust-native-code; the window shows a warning in red before every build: the mods run with full access to the computer, and their ids and versions. The Mods page says who made each one, and "Not made or endorsed by the Horizon project" for any mod not Horizon's own. - The registry (horizon-rust/horizon-mods) lists them as their own kind,
native-mod, only after a maintainer has read the source and the repository they come from. Its entry names the GitHub repository (repo); each release of it with a.hmodasset is a version once the bundle passes the registry's checks, with no further review (nothing is built or run by the registry; the player's launcher builds it behind its warning). The.hmodcontains the flat Rust project and may containassets/,data/and an optionalicon.png. The launcher checks the bundle without running anything. Native code is trusted. The file is pinned by SHA-256 like every registry file; its identity and exact build pins come fromhorizon-mod.toml.revoked.tomltakes a bad release down. A project's kind never changes. - Isolation of data. Native mods run only in the instance they are installed in, with its worlds, options and logs (the default Horizon instance's, when they are installed there); other instances play their own versions.
- What this does not do: sandbox the compiled game. horizon-mixins' checks stop mistakes, not a malicious helper (security notes).
Multiplayer
- Single player and LAN host: the mods run in the host's game and its integrated server, so a server-side hook applies to every player of the world.
- Joining a server: client-side hooks run locally. Configuration requires matching both-side mod ids and versions; a refusal names the missing, extra or different-version mods (configuration policy).
- Dedicated server: native mods run in a standalone engine export built with
--server-output, not in an instance build (--server-outputconflicts with--instanceand needs an explicit modFOLDER). See Standalone server export.
Standalone server export
horizon-launcher native-build FOLDER --server-output DIR --version TAG --trust-native-code [--profile PROFILE] [--allow-jar-downloads]
Builds a standalone dedicated-server engine into DIR, which must not exist yet. FOLDER (a mod folder or a
folder of them) is required; no instance is read, selected, edited or played, and no launcher config is written.
It reuses the pinned toolchain, source and horizon-mixins and compiles --no-default-features (no audio or
controllers). --version pins the Horizon release/Nightly the
export reuses (explicit for reproducibility). The export holds the built horizon-engine executable, prepared resources
the canonical horizon-native-mods/<id>/ projects and the provenance native-mods.json, and prints its absolute path. It runs on the same OS/architecture as the
host only (no cross compilation), needs a prepared installed launcher base release, and survives cache pruning
because it is copied outside the managed folders. On Windows use the printed horizon-engine.exe path.
For wasm mods that declare internals, put their .hmod packages directly in FOLDER, alongside any
native projects. A folder containing only wasm packages with internals also works. The export compiles
their declared internals and raw listener targets into the engine's table, without consulting an
instance or its enabled-mod list. The selected paths are recorded in native-mods.json and changing
them selects a new cached build. Packages without wasm internals do not select table entries.
Install and enable the same .hmod packages in the server game folder's horizon-mods/ as usual;
the export carries the compiled table, not those packages or the server's configuration.
A mod's crate depends on horizon-mod-api and never on the game: a horizon-client dependency,
under any name or target, is refused naming the mod and the key, before toolchain work. The mod API
has no sound or controller features, so server and client builds stage the same crate.
Declared jars use the same exact-hash preparation as ordinary launches. A server export refuses
missing jars by default and never asks interactively; --allow-jar-downloads permits the shared
preparer to download the exact files into the local cache. Cached jar bytes are never exported.
When moving the runtime to another machine or game home, prepare its jar cache separately; errors
name the required files and cache paths. No Java code from a jar runs.
Point a server at it:
horizon-server --dir SERVER config set engine.path=/absolute/export/horizon-engine
horizon-server --dir SERVER restart
Updates do not rebuild a pinned engine.path: build a new export folder, set engine.path to it, restart, and keep
the old folder for manual rollback. The supervisor does not auto-update or rebuild a pinned engine path
(remaining work in horizon-rust/horizon#6304). To inspect the running server, its authenticated
GET /api/v1/mods queries the engine's canonical LoadedMods inventory:
mods lists loaded native, wasm and content package ids and versions in inventory order.
provenance separately reports the configured program's build record, which may differ and is never
used as an inventory fallback. A stopped or unreachable engine returns HTTP 409, not an empty list.
Configuration mod matching is implemented as described above.
Native mods run unsandboxed: only build code you have read and trust.
--trust-native-code records the operator's decision; an agent must not supply that consent.
Developer acceptance workflow
Use a disposable server folder and fresh homes when developing:
SCRATCH=$(mktemp -d)
export HORIZON_HOME="$SCRATCH/game" HORIZON_LAUNCHER_HOME="$SCRATCH/launcher"
export XDG_DATA_HOME="$SCRATCH/data" XDG_CONFIG_HOME="$SCRATCH/config"
export HORIZON_NO_AUDIO=1 HORIZON_LAUNCHER_NO_SELF_UPDATE=1
export HORIZON_LAUNCHER_TRASH="$SCRATCH/trash"
export HORIZON_LAUNCHER_CLIPBOARD="$SCRATCH/clipboard"
export HORIZON_LAUNCHER_DESKTOP="$SCRATCH/desktop"
Install and prepare the base release in that scratch launcher home first, then test your own licensed bundle:
horizon-launcher native-build bundle/ --server-output $ENGINE --version TAG --trust-native-code(scratch homes,HORIZON_NO_AUDIO=1,HORIZON_LAUNCHER_NO_SELF_UPDATE=1).horizon-server --dir SERVER config set engine.path=$ENGINE/horizon-engine;horizon-server --dir SERVER run.- Join with the same mod's client half where the hook needs one; test lifecycle, tick, command and net hooks.
- Headless compatibility: the mod's server code must not require an audio device; run the server audio-free (
HORIZON_NO_AUDIO=1).
Level sounds
Implement play_level_sound_at_position or play_level_sound_at_entity to edit
or cancel a level sound before it is broadcast (server) or played (client).
Match the hook's HostSide to distinguish the requests; the event's sound,
source, volume and pitch describe that side's request, and original volume and
pitch remain available after edits. Returning Flow::Cancel suppresses that
request. A server cancellation sends no packet, so there is no client request.
Incoming sound packets, including /playsound, ask the client level hooks.
An entity-sound packet stays bound to its emitter. UI clicks, engine-local
sounds and ClientHost::play_local_sound do not ask these level hooks; the
sound engine's play_sound hook remains a separate, later interception point.
Client-predicted level sounds and LocalPlayer.playSound use those same hooks.
Canceling their sound does not cancel the player action or undo a predicted
block change. A local player's ordinary sound is an AtEntity request but
still plays at the requested position; only a genuinely entity-bound sound
(such as a goat horn) follows its emitter after playback starts.
| Client route | Level event | Vanilla call being represented |
|---|---|---|
| Placement confirmation, predicted doors/buttons/levers | AtPosition | BlockItem.place, block useWithoutItem → Level.playSound |
| Waxing, tool transforms, bucket use | AtPosition | HoneycombItem.useOn, tool useOn, BucketItem → Level.playSound |
| Game-event arrow hit (Players), pufferfish sting (Neutral), elder-guardian curse (Hostile) | AtPosition | ClientPacketListener.handleGameEvent → Level.playSound |
| Own steps, swimming, splash, amethyst chime, honey slide, fall | AtEntity | Entity/LivingEntity → virtual LocalPlayer.playSound |
| Own hurt/death, bundle menu clicks, consume effects | AtEntity | LivingEntity, BundleItem, Consumable → LocalPlayer.playSound |
| Player-bound sounds, including the local goat horn | AtEntity | Level.playSound(except, entity, ...) |
| Block-breaking/level-event effects, explosions, pickup packets | None | Client-level playLocalSound or direct sound manager playback |
| Dragon/fang/sniffer local effects, ticking ambience, UI | None | Entity-local effects, sound instances or widgets, not playSeededSound |
Server-only gameplay sounds keep their existing broadcast-time event. They are
not moved earlier merely because their implementation also calls Audio.
Engine playback and live parameters
play_sound also observes transient playback updates. event.starting is true for a
new sound and false while it plays. event.instance identifies the same engine-local
instance across those calls. At an update only volume and pitch edits apply;
None stops playback. Invalid edited volume (negative or non-finite) or pitch
(non-positive or non-finite) refuses that refresh. Updates do not replay the sound,
resolve a different source, or modify the instance's stored settings. This is the
same interception point, not a parallel sound_play hook.
Client particles
ClientHost::add_particle submits the API's ParticleOptions, a world position and
velocity to the ordinary client particle queue. The client must have a loaded player
and world. Non-finite numeric inputs are refused. A successful submission does not
promise a visible particle: provider availability, sprite sets, distance and the
player's particle settings still apply. This method is not available on ServerHost.
Particle options carry a ParticleKey, not an engine particle enum or a provider name.
Request the key with Registrar::particle_key during registration and retain it. The game resolves
provider identities once when content loads. ParticleOptions::simple(key) handles both vanilla
and custom sprite providers; custom providers still require an assets/<namespace>/particles/<path>.json
sprite set. All option constructors take the resolved key, including dust, item, trail,
vibration, shriek, sculk_charge and dust_transition. Use the corresponding vanilla key
for these parameter families. Sending an option performs no string parsing or registry scan.
For stack-derived breaking or eating particles, call
ClientHost::item_particle_options(&stack) and pass the returned options to
add_particle. This read resolves the ground model with seed 0, preserving all
layer particle sprites and resource-pack and stack-component overrides. It returns
None for invalid stacks or unavailable resources/provider. It is available in
Setup, Reloading and World, on native and wasm. Resolve again after resource reloads.
It allocates a sprite list only when called; it adds no per-tick work.
Bot movement and block collisions
PlayerRef::simulate_move predicts one server player's movement tick from
PlayerInput, using the game's movement implementation without replacing the
live player's body. It returns SimulatedMove with position, velocity, ground,
horizontal-collision and sprinting results. It refuses stale or inaccessible players,
dead players, spectators, riders and invalid numeric input. A supplied velocity is
input to the prediction, not a write to the player.
LevelRef::collision_boxes reads block collision boxes within a finite, ordered
axis-aligned box. It excludes entity and border collisions. A refused level or a
query covering more than 1,000,000 cells returns None; a valid query with no
collisions returns Some containing an empty vector. Keep queries bounded rather
than scanning a whole level each tick.
LevelRef::block_shape(host, state, pos, kind) reads an explicit BlockState's
outline or collision boxes with an empty context at a position in that level.
The supplied state can differ from the world's block, without changing it;
level-dependent shape inputs still come from the actual level. To query the
stored block, first read level.block_state(host, pos) and pass that state.
This server read returns block-local boxes, or None for an unknown state,
stale handle, closed level or client. Parked levels are supported.
These queries take typed handles for their natural receiver. There are no parallel UUID-only or dimension-string helpers.
Extending block entity validity
Use Hooks::block_entity_type_add_blocks after retaining block and type keys from
register. event.modify(kind, &[block]) extends one existing type;
event.modify_matching(predicate, &[block]) extends all matching registered types.
The event uses a RegistrationContext and runs before worlds load. Duplicate additions
are harmless. Unknown keys and blocks that do not share the target's common vanilla
superclass fail startup.
A block may be valid for several entity types. Its native declaration or original
vanilla template still selects creation and ticking. For example, a block declared
with template: "minecraft:vault" and added to minecraft:vault creates and saves the
vanilla vault entity. Adding another valid type does not replace that factory. Sign
templates retain their existing membership defaults. Validity is frozen with the
canonical type table and participates in the multiplayer content identity.
Typed bulk block edits
BlockEdit.palette contains complete BlockState values. Construct them with
BlockState::of(&host.block_states(), block, constraints) (or change an existing state
with with). filters contains owned StatePredicate values. StatePredicate::block
validates property names and values strictly; StatePredicate::tag retains arbitrary
property requirements, including for an empty tag, and rejects duplicate properties.
A predicate requires block identity or tag membership AND every supplied property. Omitted properties are wildcards, never default-state requirements. An unknown or missing property on a tag member never matches. Sparse, non-Cartesian state tables are supported: a partial predicate does not need to form a complete state with defaults.
All palette entries, predicates and run indices are prepared before any mutation. NBT is not representable. At most 65,536 filters are allowed. Unloaded chunks and out-of-height portions of runs are skipped. Edits preserve strict block-entity behavior, notify no neighbors and send whole changed chunks.
ColumnBlocks.palette also contains complete BlockState values. States of the
same block with different properties have distinct entries. Only distinct numeric
states are converted to API values, not each block in the returned columns.
Refusing dimension travel
entity_travel_to_dimension runs before a server entity moves to the target
level. Returning Flow::Cancel refuses that entity's travel. Passengers are
asked recursively: refusing one leaves it dismounted in the source level,
with its own passengers, without refusing the vehicle's transition. Discarding
an entity through its handle during the callback also prevents its travel.
Callbacks may change riding relationships. An original passenger edge is restored
only if the checked mount operation still permits it; otherwise the passenger
stays behind, without overwriting any new vehicle chosen by a callback.
A falling block ticks outside the level's entity list, so its handle is not
accessible during this callback.
A local unsigned developer base
A fresh launcher home can use a game built from a trusted checkout without installing a signed release first. This is a developer route, not a release channel: the launcher labels the base and its derived games Local unsigned developer build, including the source commit. Automatic updates never replace it. The game sends neither telemetry nor the live-count ping when launched on this base.
Commit tracked changes before building. From that clean checkout, build the game and its companion programs, then compile the original Horizon exporter classes against your own pinned Minecraft installation:
cargo build -p horizon-client --bin horizon-client -p horizon-client-lib --bin horizon-ctl \
-p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
bundle=$(target/debug/horizon-prepare compile-exporters --root "$PWD" | tail -n 1)
mkdir -p exporters
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
horizon-launcher developer-install "$PWD" --program "$PWD/target/debug/horizon-client" --trust-unsigned
--trust-unsigned permits executing the checkout's build code, not just copying the game. The command
runs the existing cargo run -p xtask -- package release packager. Trust both the checkout and the executable
with your computer's full rights. Missing companion programs, missing or incompatible exporters, dirty tracked
inputs and executables for another platform are refused. Nothing publishes a release or changes the selected base.
The returned developer-… tag hashes the packaged files and a complete tracked HEAD source snapshot. Select
that exact tag, prepare its resources from your own Minecraft installation, and pin an instance to it:
horizon-launcher settings selected-version DEVELOPER_TAG
horizon-launcher prepare --version DEVELOPER_TAG --minecraft YOUR_MINECRAFT_FOLDER
horizon-launcher instance create Development
horizon-launcher instance version Development DEVELOPER_TAG
horizon-launcher native-build YOUR_NATIVE_MOD_FOLDER --instance Development --trust-native-code --profile developer
Native builds use the saved local source snapshot, including commits never pushed to GitHub. They do not fetch
Horizon source from GitHub or silently fall back when that local snapshot is missing. A changed checkout needs
a new commit, rebuilt executable and another developer installation. import-build --version DEVELOPER_TAG
can also reuse this base; an unselected developer base is never chosen as an automatic fallback instead of a
release or Nightly. Remove it with the normal horizon-launcher remove DEVELOPER_TAG command when no longer needed.
Reading data maps
A data map attaches a value to the entries of a registry (NeoForge's DataMapType), here
minecraft:item and minecraft:block. Data packs and mods' files set the values; a mod reads
what the server made of them, so a server pack can rebalance a mod's numbers without a rebuild.
Describe the map once with DataMap: its id, the registry its entries are in (Entry, an
ItemKey or a BlockKey), and how a value decodes. Values arrive as the NBT form of the JSON a
file held: a whole number is an Int, or a Long past i32, a fraction a Double, a boolean a
Byte, an object a Compound and an array a List. Integer tokens must fit signed
64-bit NBT Long; larger unsigned integers return DataMapError::Undecodable,
never a rounded Double. Fractional/exponent tokens use the JSON parser's f64
value (including an integral value written as 1.0). A JSON null anywhere in a
value also refuses the entire read as Undecodable; it is not an empty string.
Empty and heterogeneous arrays preserve their elements as in-memory List values.
These rules are identical for native and wasm readers.
use mod_api::{DataMap, DataMapTypesEvent, ItemKey, ServerHost, Tag};
/// `mymod:emc`: an item's value.
struct Emc;
impl DataMap for Emc {
type Entry = ItemKey;
type Value = i64;
fn id(&self) -> &str {
"mymod:emc"
}
fn decode(&self, value: &Tag) -> Option<i64> {
match value {
Tag::Int(value) => Some(i64::from(*value)),
Tag::Long(value) => Some(*value),
_ => None,
}
}
}
// In `impl Hooks`: register it each time the server loads its data.
fn data_map_types(&self, _: &mut dyn ServerHost, event: &mut DataMapTypesEvent<'_>) {
event.types.push(Emc.ty());
}
Its values are data/mymod/data_maps/item/emc.json in any data pack, and files the mod pushes
through data_pack_files under data_maps/mymod:item/emc:
{ "values": { "minecraft:coal": 10, "#minecraft:logs": 32 } }
Read an entry with item.data(host, &Emc) or block.data(host, &map), from any hook with a host:
Ok(Some(value)): the merged value. The newest file wins, a#taggives every entry in it the value,replacedrops what earlier files set andremovetakes entries away.Ok(None): the map gives this entry no value.Err(DataMapError::UnknownType): no mod registered the map for that registry in this load.Err(DataMapError::Undecodable): the value is not onedecodetakes.Err(DataMapError::Unavailable): no maps are loaded here. Data maps live on the server (the single player server thread included) and are not synced to clients, so a client always answers this, as does a server before its world's data first loads.
The server merges every map when its data loads: a world's first open and every /reload. A
read sees the last load whole, never part of one, and a value a reload drops is gone at once.
data_maps_updated fires after each load, the place to rebuild anything computed from the
values; reads also work in the world. A lookup reads the loaded map: it parses nothing and
touches no file.
Wasm mods read data maps the same way, with the same answers.
Machine-readable SDK catalog
Tools discover a build's hooks and injection points through mod-catalog.json,
shipped at the root of each prebuilt SDK and beside points.json in a developer
source build's catalog directory. Converters, linters and IDE helpers must read
this file rather than source paths. Internal source layout is not a tool API.
Fork Nightlies also attach mod-catalog.json as a standalone release asset,
listed in the signed SHA256SUMS. Download the current
catalog asset
from the fork Nightly release,
or use the corresponding dated release for an older pinned build.
It is generated from the Nightly's exact published commit using the same
generator as the SDK. Verify it with that
release's checksums and build-specific signature; do not mix the latest catalog
with an older pinned build. The catalog follows the release when it rotates to
a dated Nightly tag and has the same retention as the other Nightly assets.
The JSON document has schema_version: 1, api_version, hooks and points.
Consumers must refuse unknown schema versions. The API version is the mod
manifest API version; it is independent of the catalog format version.
Hooks are sorted by id and describe side, nullable scope (Part kind and
key), contract, ordered arguments with API name and type, and nullable
output. Types are Rust API event field types, including their borrow lifetimes.
Points are sorted by name and target; a target distinguishes equal names.
Each includes its supported access values (api, internals), API record
and ordered args, return contract and type, raw shape when available, and
nullable api_refusal. Refused and native-only points remain in the file.
The build generator owns this catalog. Output has no timestamps or absolute paths. A format change requires a schema version change; tools should use the catalog accompanying the build they target. CI regenerates the catalog in-test and compares the two documents for determinism. The full file ships in the SDK, developer source build and fork Nightly release; no committed fingerprint needs refreshing when independently authored hooks land.
Server item particles
Set ParticleOptions::item_stack(item_particle_key, stack) and call
level.send_particles(host, &options, state, at, spread, speed, count) with a
LevelRef and a ServerHost. The key is the
load-resolved minecraft:item particle key; the stack is an API ItemStack.
The same request crosses the native and wasm boundaries through Wire.
The server validates the stack against the registered items and component and
count limits. Invalid stacks return false without queueing a particle. A true
result means queued, not necessarily drawn. The ordinary particle packet carries
the item stack, not server-selected sprite names. Each client resolves its ground
item model with seed 0, through the same path as
ClientHost::item_particle_options, so its resource pack and stack model
components determine the layer particle sprites.
The resolved ParticleOptions::item sprite list is for local rendering; it is
not a substitute for the stack on server sends. Block, block-marker, falling-dust,
dust-pillar and block-crumble requests still require a valid block state argument.
Trail and vibration requests are still refused by the server send.
Registering cauldron interactions
Use Hooks::register_cauldron_interaction with a RegistrationContext and
RegisterCauldronInteractionEvent. Retain typed BlockKey and ItemKey values
from registration, as described in the registry-key chapter. The hook runs on both
sides when preparing the resource snapshot; publication happens only after the
complete candidate validates. Failed preparation retains the previous snapshot.
The names correspond to NeoForge 26.1's documented
RegisterCauldronInteractionEvent, Dispatcher, and Interaction. They remain
unverified for NeoForge 26.3. See the public cauldron documentation.
Register an exact item on a block's dispatcher:
fn register_cauldron_interaction(
&self,
_: &RegistrationContext,
event: &mut RegisterCauldronInteractionEvent<'_>,
) {
let handler: Arc<dyn Interaction> = Arc::new(Syrup { empty_bottle: self.glass_bottle });
event.interactions.register(self.empty_cauldron, self.syrup_bottle, handler.clone()).unwrap();
event.interactions.register(self.syrup_cauldron, self.syrup_bottle, handler).unwrap();
}
A custom block opts into dispatch by registration; its name need not end in
cauldron. Register the filled target too when a plan replaces the empty block
with a custom cauldron. Its declared state schema must admit the proposed state,
for example level=1 in a 1..3 level property. register_to_all(item, handler)
queues registration over the complete prepared dispatcher set, including custom blocks
registered later in the hook sequence. Conflicts surface when preparation finishes;
they fail the whole preparation and name both owners. register_to_all does not
return Duplicate directly. Immediate admission errors from either registration
method are also stored, so ignoring an error cannot publish a partial snapshot.
Vanilla entries are prebound in the same table. With register, replacing one,
or registering a second entry for the same dispatcher and item, returns
CauldronRegistrationError::Duplicate { owners }, naming the existing owner and
the attempted owner, and refuses publication. There is no override flag. Unknown
block or item keys refuse registration. On water cauldrons, vanilla's dye tag
always precedes exact-item entries: even washing that returns Pass does not fall
through to the mod's entry.
Implement Interaction::decide(&dyn Host, &CauldronUse) for eligibility. It reads
the complete captured held stack, target state, actor, hand, dimension, creative
flag and underwater flag. Return Pass to leave normal item use available,
Consume to stop it without a plan, or Success(CauldronPlan) to propose a block
replacement, one held-item exchange and deferred effects. The exchange uses the
existing survival, creative, inventory insertion and overflow-drop rules.
For example, a handler can propose a syrup-bottle exchange without changing the world during the decision:
struct Syrup { empty_bottle: ItemKey }
impl Interaction for Syrup {
fn decide(&self, host: &dyn Host, _: &CauldronUse) -> CauldronDecision {
let Some(filled) = host.block_state_id("example:syrup_cauldron[level=1]") else {
return CauldronDecision::Pass;
};
CauldronDecision::Success(CauldronPlan {
block: Some(filled),
exchange: Some(ItemStack::new(self.empty_bottle, 1).unwrap()),
sound: Some(Identifier::parse("minecraft:item.bottle.empty").unwrap()),
fluid_event: Some(Identifier::parse("minecraft:fluid_place").unwrap()),
statistics: vec![
Identifier::parse("minecraft:use_cauldron").unwrap(),
Identifier::parse("minecraft:used").unwrap(),
],
})
}
fn fire(&self, _: &mut dyn ServerHost, _: &CauldronUse, _: &CauldronPlan) -> CauldronFire {
// No extra writes: the engine applies the validated plan.
CauldronFire::Applied
}
}
A transfer can carry two distinct statistics: for example
minecraft:use_cauldron and minecraft:used. The latter awards the used-item
statistic for the original held item's key. Each is awarded once; duplicate
statistics and plans with more than two statistics refuse the action. A plan may
also carry one sound and one fluid event (minecraft:fluid_place or
minecraft:fluid_pickup). State and returned stacks are validated against the
current resources before mutation.
Interaction::fire(&mut dyn ServerHost, &CauldronUse, &CauldronPlan) runs only on
the server, inside the shared action transaction. Return CauldronFire::Applied
to permit the plan and supported callback writes to commit. Refused, a panic,
a guest trap, stale preconditions or an enclosing abort discards the action.
Sounds, statistics, events and overflow drops wait for root commit. Uses the
shared transaction and runtime limits; no cauldron-specific budget.
Client prediction selects the same entry, but skips custom handlers entirely:
it does not call decide or predict writes and consumes the local action while
waiting for the server result. Vanilla handlers continue to predict normally.
The server owns inventories, statistics, drops and game events. Matching custom
binary builds and synchronized resource generations are not established by the
current handshake, so custom prediction is suppressed. Native and wasm handlers
use the same generated registration and callback route;
CauldronFire defaults to Refused when a generated callback cannot run.
Dropped items during block-entity ticks
The overlay below is the port's own transactional requirement, not vanilla or NeoForge behavior. NeoForge adds no event, deferral or drop capture around a block entity's tick; new item entities join immediately. Block-break and entity death/shear captures are separate mechanisms.
During a mod's BlockEntityPart::block_entity_tick, existing dropped-item stack writes and removals are proposals. Use ItemEntityRef::set_stack to replace a complete nonempty stack and EntityRef::discard or EntityRef::remove to remove it. Item-place writes join the same scope. Native and wasm mods use the same calls; there is no separate batch method or transaction seal to finish the tick.
Later Parts see earlier proposed stacks in registration order. A proposed removal hides the item from handle resolution, item narrowing, entity selectors and item-place reads. Explicit nested host transactions can abort their own proposals; committing one still waits for final tick acceptance.
The engine validates the final block-entity snapshot and every item's original stack and residency incarnation before publishing either side. Rejected revisions, invalid stack conversions, malformed guest responses, stale item preconditions, and removal or replacement of the ticking block entity discard the proposals. A successful host setter means the proposal was accepted into the scope, not that final writeback has already committed. A numeric entity ID never retargets a replacement or another level. Removal notifications run after publication and after detaching the completed overlay.
This is deliberately not a whole-world transaction. Pickup delay and target changes, spawning, block changes, motion and teleportation, non-item removals, players, attachments, neighboring block entities, weather and world effects remain deliberately immediate. They are not undone when the snapshot is rejected. An immediate operation that destroys a staged item's precondition can reject the pending proposal while retaining its own effects.
With no scope open, the engine behaves exactly as vanilla for these item operations: changes are immediate, the overlay remains None, and nothing is staged. The prepared-resource adapter regression native_tick_publishes_or_rejects_item_proposals_with_writeback checks that its outside-scope setter changes the item immediately and leaves both overlay and tick key None. Vanilla block-entity tick item changes are immediate too; only the port's mod block-entity host operations gain the stronger atomicity above.
Level block notifications and cracks
level.send_block_updated(host, pos) tells clients to read the current block again.
It does not change that block or mark the chunk unsaved. Repeated notifications of
one position are coalesced until the update log is consumed. Inside a transaction,
notification waits for the outermost commit; aborting sends nothing. A stale,
parked, unloaded or out-of-height write returns false and queues nothing.
level.set_break_progress(host, breaker, pos, stage) broadcasts a server crack.
Use a stable negative breaker ID for a machine. Stages 0..=9 draw a crack;
any other stage clears it. This is distinct from
level.set_client_break_progress(client_host, breaker, pos, stage), which draws
only in the local client's view and requires &mut dyn ClientHost, not a server
host. Local cracks expire after 400 ticks without an update and clear when the
client changes levels or disconnects.
level.block_break_speed(host, pos, tool) reads the tool's destroy speed on that
block, including its minecraft:tool component rules and default speed. It does
not include Efficiency, player attributes, effects, fluid/airborne penalties or
block hardness. Bare hands return 1.0; an unavailable position returns None.
This read takes an immutable host and does not modify blocks or tools.
Measuring styled text outside drawing
A tooltip component can determine its dimensions before it receives any draw
context. Use ClientHost::text_width(&Text) and text_split(&Text, width):
these are reads of the client's active font in unscaled GUI pixels, also usable
in client setup. Native and wasm mods use the same methods.
Construct an owned Text with its content, style and children. A bridge with a
flat component representation can build a TextForm and call
Text::try_from(form) once at its boundary; the host reads accept that validated
Text, not JSON or engine text types. Both methods return a TextFormError if
the supplied component cannot be represented at the engine boundary.
Width is the same styled and translated width used by the engine's tooltip
layout. Wrapping follows Font.split(FormattedText, width): it keeps styles,
breaks at newlines and fitting spaces, and uses the vanilla one-pixel minimum
for widths below one. Returned lines are API-owned Text values. Translation
uses the current client language; a later language or resource-pack font change
is reflected by the next call, so do not retain dimensions across reloads.
NeoForge's ClientTooltipComponent receives a Font in its width and drawing
methods. These reads use that same role—the active client font—without passing
an engine font object or requiring a DrawContext. They do not draw anything
and do not expose a full rendering context.
For vanilla Component.getString() behavior, use
ClientHost::text_string(&Text) -> Result<String, TextFormError>. This client
setup read also works outside drawing on native and wasm. It uses the same
component conversion as measurement: translation arguments, key names and nested
siblings resolve against the current language on every call. The result contains
plain characters, not serialized JSON or translation keys. Styles do not add
characters; literal legacy § formatting codes remain in the string, unlike the
font's formatted rendering. Invalid components return the same boundary errors
as the other text reads. Do not cache resolved strings across language changes.
For vanilla Component.getVisualOrderText() behavior, use
ClientHost::text_visual_order(&Text) -> Result<Vec<VisualTextCharacter>, TextFormError>.
Each API-owned Wire record contains a Unicode scalar (scalar), its full
resolved TextStyle, and the sink's source_index. Characters are already in
visual order, including Arabic shaping and mirrored punctuation; do not apply
bidi a second time. This read uses the engine's existing language and bidi
pipeline, is available outside drawing including client setup, and is identical
on native and wasm. It has no documented NeoForge hook counterpart.
Sink indices are UTF-16 offsets local to a constant-style substring within a
directional run, not global component positions. For example, A😀B yields
indices 0, 1, 3; Hebrew שלום yields 3, 2, 1, 0. A style or bidi run boundary
restarts the index. Shaping can shorten the string, so these are not guaranteed
to identify original input scalars. Use the records to re-layout existing tooltip
lines without losing click, hover, font or other style information. Like the
other reads, invalid component input returns its boundary error.
Action keybind IDs such as key.jump resolve through the client's current key
binding to their assigned physical key display name, matching vanilla's KeybindContents.
Physical key names resolve normally.
Native mods
Behaviour that needs code (a new key, a HUD, a tool that changes how mining works) is a native mod.
It is one flat Rust project with horizon-mod.toml, Cargo.toml and src/, beside its assets/ and data/.
Start with cargo horizon-mod new my_mod --template native and implement the typed hooks;
raw injections are a rare escape hatch (Injections). How the launcher builds one, what it pins and why it is not sandboxed:
native mods.
The typed hook API is generated automatically at build time, in debug and release builds alike; its generated
Rust files are build output, not files to regenerate or commit. Engine contributors adding a hook only run
cargo run -p xtask -- mod-api to fill its hand-written placeholder body
(Adding a hook).
A native mod's config files go in the game's shared config/ folder, beside options.txt (on a dedicated server,
beside server.properties), as Fabric and NeoForge mods' do: the mod picks its own file names, such as
config/<mod id>.json or a folder config/<mod id>/. It reads and writes them with Host::config_files(), which is
bound to the calling mod and refuses any other mod's files. A mod that lists its files with
native_register_configs gets them read for it, written from their defaults when missing, and handed over again
when edited (native_config_loading, native_config_reloading, as NeoForge's ModConfigEvent), and a world can
keep its own server config in serverconfig/ (native mods). Settings kept with set_setting stay in horizon-mods/.horizon-mod-data/<id>/.
setting and set_setting are bound to the calling mod and its registered keys: naming another mod's ID or an unregistered key never reads or changes a file.
Typed reads cache values by data folder and key; a successful set_setting invalidates that key, while a failed write leaves the cached value unchanged. Raw setting reads access the file.
Register typed settings with Registrar::register_setting to put them on Options > Mods; edits persist before
setting_changed runs, and only the owning mod receives that event.
8c838a6b381b93150deaeed2057acd1
For a client HUD's session information, use the read-only
ClientHost::client_session() service and the local PlayerRef's
block_breaking and ambient_mood reads rather than raw injections. Missing
sessions, external display names and memory addresses remain explicit optional
values; all counters stay local. See client session and local-player status
for their exact availability and frame-time semantics.
native_compute_fov_modifier(player, base, modifier) runs on the client each tick for the local player's FOV scale, after vanilla effects and before zoom and camera easing. player contains the calculation inputs and base is the original scale. Non-finite modifiers or values outside 0.01..=10 fall back to base. It does not affect a spectator camera on a mob.
The client hook native_render_name_tag(entity_id, kind, vanilla_visible, visible, text) runs at player and mob, armor stand, and item frame name decisions before shared geometry. Set visible to override vanilla and text to Some literal replacement text (NeoForge RenderNameTagEvent). Distance and entity availability still apply; the local player's tag is considered only when its own camera and settings gates allow it.
Native mods are Rust projects that implement the game's typed hook API (impl Hooks). Start with
cargo horizon-mod new my_mod --template native (Trait mods).
Each project has one horizon-mod.toml, with exact build pins in [native], and ships as one .hmod.
For the rare case the API does not cover, internals injections use compiled raw slots when available;
unlisted supported targets use a source rebuild. See Injections.
An injection is an escape hatch; request a typed hook for the behavior it needs.
Native mods are native code with the player's rights, not sandboxed. The first install shows a trust notice, and every build waits behind a red warning; the registry lists one only after a review (its later releases are checked by machine). They play in a game of their own, built for the instance they are installed in.
The game's Mods screen only lists code mods (it has no switch). A code mod's settings page, when it has one, opens from the Config button. The launcher decides which mods are on. A browser install that finishes after a world opens waits until the world closes before activating its content.
Villager handles preserve the full signed integer level (at least 1), including values above the five vanilla trade tiers. Higher levels use the master badge and do not gain further vanilla trades.
Measuring native mod calls
An administrator with permission level 3 can run /modprofile start, exercise the mod,
then run /modprofile stop. Profiling is off by default. Starting twice or stopping without
an active session reports an error. /modprofile report prints the last completed summary
without writing a file or stopping an active session. Feedback goes only to the sender.
The same controls work through horizon-ctl command modprofile start (and stop or report).
Stop saves one local, atomically written profiles/modprofile-<timestamp>-<counter>.json
under the game's folder. The export is bounded to 8 MiB; a save failure is reported and the
stopped summary remains available. Load the file locally in Firefox Profiler's Marker Table;
Horizon never uploads it. The raw Gecko version 37 markers contain mod/hook/Part identities,
counts, exact nanosecond timings and log2 histograms, never arguments or player/world data.
Timings are inclusive: an outer call includes nested calls. These are process-wide aggregates,
not invocation traces or sampling shares. This first command release measures native guarded
calls only; wasm timing and a dedicated profiling guide follow separately.
Server services and player handles
Server hooks reach commands through host.commands(): run_command(line) uses
console permission level 4 and the existing console command path, while
feedback(text) answers the current command source. host.messages() provides
broadcast(text) (including the broadcast hook and server log) and
text_allowed(text) (the server chat filter).
On the client, host.chat_line(Text::literal("Local notice"))? adds a line
only to the local chat HUD and history, without sending a packet or firing
client_chat_received. Handle its Result<(), mod_api::TextFormError>: text exceeding the
boundary's size or nesting limits is refused before it reaches the HUD.
host.players().server_players() returns Vec<PlayerRef> in connection order;
host.players().acting_player() returns the optional PlayerRef whose state is
swapped in for this hook. The online list includes connected players even during
nested player turns; a displaced body's reads still refuse where they did before.
host.players().player_by_name(name) finds a connected real player by account
name, case-insensitively, or returns None; it does not match nicknames or fake
players. host.levels().list_levels() returns loaded server level handles in
vanilla dimension order, or an empty list before a server world opens. Both
services are available through &dyn ServerHost and do not mutate the world.
Read identity through player.uuid(), player.name(host),
player.permission_level(host) and player.address(host). The address is the
connection IP without a port (the forwarded address behind a Velocity proxy),
empty for an acting player with no known connection. Name, permission and address
reads retain connected identities during displacement and refuse stale handles.
player.level(host) returns the authoritative LevelRef, and
player.dimension(host) returns its dimension Identifier, including registered
custom dimensions. These reads take &dyn Host; dedicated and integrated servers
read the player's current dimension, while a client reads its local player's level.
They return None for unknown,
disconnected, replaced or stale player handles. Connected players retain their
current dimension through nested guest turns; the level may be parked rather
than the dimension whose tick is currently running.
Use the entity and living handles for other world-state reads.
No snapshot of event data is returned by these services.
Send to one player with player.tell(host, text), action_bar(host, text) or
tab_list(host, header, footer). set_nickname(host, nickname),
set_sleep_ignored(host, ignored) and hide(host, permission) keep their existing
server behavior. Messaging reaches the acting player and other connected players,
including displaced online records; it refuses stale or disconnected identities.
Vanish state is stored by UUID and can be changed with a retained handle after
disconnection, until its world epoch closes. It cannot target arbitrary offline
UUIDs; retain the handle value, not the borrowed service. Nickname and sleep
writes still require an online player. The settings survive reconnects.
A player handle also answers the game's own rules for that player, with the checks and state
the real actions use: may_use_item_at(host, pos, face, stack) (Player.mayUseItemAt: builders
always may, adventure and spectator players only with a matching can_place_on) and
can_harm(host, other) (ServerPlayer.canHarmPlayer: the pvp rule and their teams).
respawn_config(host) reads the respawn point (Some(None) for none) and
set_respawn_config(host, config) writes it as /spawnpoint does, player_set_spawn veto
included. text_filtering(host) is the option the player's client announced. For the night skip,
sleep_ignored(host) reads what set_sleep_ignored left, counts_for_sleep(host) says whether
the player is counted, and sleep_timer(host) / set_sleep_timer(host, ticks) read and write
Player.sleepCounter (0 to 109; 100 is slept long enough). Reads take a Host, writes a
ServerHost.
These small services exist only on ServerHost, not Host, ClientHost or a
restricted level host. Native and wasm mods use the same calls: service methods
are generated imports, and acquiring a service just reborrows the current call's
frame resource. Block and fluid scheduling stays on LevelTicksHost, and
tick_stats() stays on ServerHost.
Typed server commands
Register each server usage with register_commands and Command. The
run_mod_command event carries a CommandInvocation,
not a raw line to split. Match its literals to your command path, then read named
values through command.argument(name) and CommandArgumentValue. An integer is
already an i32; a player argument is a live PlayerRef, not selector text.
Return Flow::Cancel when your listener handles the command.
The server parses and checks permissions before resolving arguments or calling a
listener. Omitted optional arguments are absent, including an omitted optional
player: they do not silently select the source player. A present player argument
must select exactly one online player; missing players or invalid input produce
command feedback without calling the listener. Word and greedy text arguments
are strings, numbers are finite doubles, booleans are typed, and item arguments
are component-bearing stacks of one item. Block positions are resolved and floored
from the source's world, relative or local coordinates without loading chunks.
A player's source is their position and rotation. The console has no source
entity and uses the Overworld spawn's block corner with zero rotation, so @s
cannot resolve there. Every source receives the same owned data, on native and wasm.
event.source is a non-exhaustive CommandSource: Player { player, .. },
Console { .. }, CommandBlock { position, level, .. },
CommandBlockMinecart { entity, .. } or Function { id, .. }.
source.permission() returns the effective PermissionLevel: functions use
function permission; blocks and carts use game-master permission. execute as,
at and in change argument resolution, not the origin. Feedback through
host.commands().feedback honours the underlying source's silence and track-output.
Custom argument types
Register a value kind once from Hooks::register, then use its namespaced id as an
argument's kind:
use mod_api::{
Command, CommandArgumentError, CommandArgumentKind, CommandArgumentType, CommandSuggestion,
Hooks, RegisterCommandsEvent, Registrar, RegistrationContext, ServerHost, Text,
};
struct Machine;
impl CommandArgumentType for Machine {
fn kind(&self) -> CommandArgumentKind {
CommandArgumentKind::Word
}
fn parse(&self, input: &str) -> Result<String, CommandArgumentError> {
match input {
"press" | "furnace" => Ok(input.to_owned()),
_ => Err(CommandArgumentError::new(Text::translate(
"example.commands.machine.unknown",
vec![Text::literal(input)],
))),
}
}
fn suggest(&self, prefix: &str) -> Vec<CommandSuggestion> {
["press", "furnace"]
.into_iter()
.filter(|machine| machine.starts_with(prefix))
.map(CommandSuggestion::new)
.collect()
}
}
#[mod_api::horizon_mod]
impl Hooks for MyMod {
fn register(&self, r: &mut dyn Registrar, _: &RegistrationContext) {
r.register_command_argument("machine", Machine);
}
fn register_commands(&self, _: &mut dyn ServerHost, event: &mut RegisterCommandsEvent<'_>) {
event
.commands
.push(Command::new("machine use <machine:mymod:machine>"));
}
}
The server parses the argument with parse before calling run_mod_command; a
rejected value shows the returned Text and no listener runs. A valid value arrives
as CommandArgumentValue::Custom { kind, value }, where kind is mymod:machine
and value is the canonical text parse returned. suggest is asked for the
completions while the player types.
CommandArgumentKind::Word takes one word of ASCII letters, digits and _ - . +; the
word ends at the first other character, a space included, so a value with : or /
needs Greedy, which takes the rest of the line and must be the usage's last argument.
Players whose client does not know the type still
parse and complete the command: the server sends the argument as a Brigadier string
of that shape with minecraft:ask_server, and their chat asks the server for the
suggestions. (Fabric sends the registered argument type's id, which an unmodded
client cannot read; Horizon's string keeps the tree valid.)
The namespaced id is in the registering mod's namespace; an id registered twice, a
name in another namespace or an empty name refuses the mods at startup, naming the
mod. A client mod registers client command argument types the same way; they are
available to register_client_commands.
player_command stays player-only pre-execution cancellation (NeoForge's CommandEvent), including vanilla commands.
When the usages a mod registers change at runtime, call
host.commands().refresh_commands() on the server. At the start of the next
server tick, every mod is asked to register again. The server rebuilds the typed
command dispatcher and sends each player's tree after player_commands_sent
filters it, as vanilla does after /reload. Repeated requests before that tick
coalesce; without a world open to players, nothing is rebuilt.
Observing advancement progress
Subscribe to player_advancement_progress to inspect the whole advancement immediately after
one criterion is granted or revoked, before its completion rewards. event.progress.criteria
includes obtained and unobtained criteria; obtained_at is the original saved grant date or
None. requirements lists the OR groups, completed_requirements counts satisfied groups,
and done is the game's completion result. A player-issued /reload mutates and snapshots
the acting player's live progress, including during a nested turn. For a suspended outer
player, reload grants still use the displaced hub record; full owner-correct reload awards
for suspended and inactive players remain tracked in #1240.
Keep event.progress.clone() if it is needed later: the owned AdvancementProgress snapshot
cannot change the player's progress and is not updated by subsequent events. No-op grants and
revokes do not fire. The snapshot uses the same plain-data bindings in native and wasm mods.
Filtering toast notifications
Implement Hooks::toast_add to filter a client notification before it enters its
queue (NeoForge ToastAddEvent). The hook receives a read-only ClientHost and
ToastAddEvent: kind is a ToastKind, and title and description are mutable
text components. Return Flow::Cancel to discard only that candidate, or
Flow::Continue after optionally replacing either component. No slot, animation
or toast sound is started for a cancelled candidate.
The families are Advancement, Recipe, Tutorial, System and Other (including
now-playing music). Match non-exhaustively. For advancement toasts, the title is
the task/goal/challenge label and the description is the advancement's name, not
its advancement-screen description. Recipe notifications keep their rotating
station/result icons; tutorial hints keep their progress bar. Edits change only
the displayed text, not these identities or lifetimes. Setting the optional
description to None removes that line.
The hook runs for new additions, not in-place addOrUpdate changes to an existing
recipe or system toast. A cancelled tutorial hint is not retried every frame;
a later, different hint is a new candidate. Title-screen backup/access failures,
narrator notices, pack download/failure and chunk IO notices all use the same
admission point. Client-lib prepares title candidates and Game admits them with
a host before applying them; candidates produced during the initial resource
loading screen wait until that handoff. The pause screen's persistent song label
is HUD content, not a queued toast, and is not filtered by this hook.
Native mod source revisions
A native mod targets one exact Horizon source revision: horizon in its horizon-mod.toml [native] section.
The same section pins game and the exact mixins commit. Match the mod to the game revision it will run with,
and rebuild it after game updates. A source pin is not a promise that a later game or launcher will keep
building the mod unchanged.
An installed native build with a missing or invalid build record must be rebuilt from an installed Horizon version rather than used as a source revision.
Hook points are functions the game calls at fixed places. Their names, parameters and call sites belong to the game source and may change. Per-hook catalog versions describe individual contracts, not minimum game versions or a cross-release compatibility range.
Hooks are declared in Rust now, not in JSON: a hook author adds a #[hook(...)]
declaration, with its /// doc, to a file under crates/horizon-mod-vocab/src/hooks/ (a new file for a new area, named
for it). A host method is not declared there: it goes in the area trait of
crates/horizon-mod-vocab/src/host/<area>.rs, with #[access(WorldOnly)], #[access(Metadata)], #[access(ClientSetup)] or #[access(PublishedResources)] on each method. The inherent Game method has the same name. The generator writes the access check and full-path call into Cargo's build output; do not write a game-side forwarder. The #[horizon_mod_api_macros::host_area] attribute generates panicking defaults for test doubles. The generator discovers area files without a shared module list. native-hooks.json and
tools/native-hook-fragments/ are retired, and a file there fails the build. Collection updates
the catalog, not a global API number, source comments or documentation histories.
Hand-written API types have a selected public surface in crates/horizon-mod-vocab/src/exports/<area>.rs:
module declarations and re-exports stay in their source area's fragment. Engine-only re-exports sit in a
separate section of the same fragment and keep their existing paths. The generator discovers all fragments
and includes each section at its original scope. A new hand-written area adds one fragment, while
catalog-generated hook types need no export-list edit. Do not add declarations or re-export lists to
lib.rs.
Runtime packages and identity
The launcher generates the game's embedded native inventory from the selected horizon-mod.toml
manifests: mod id, version and side. Cargo package metadata and hook factories do not supply identity.
The game validates each compiled mod's package at
<executable directory>/horizon-native-mods/<id>/ before installing hooks or starting a world.
Missing, unreadable, content-only or identity-mismatched packages refuse startup with the affected
mods and paths; restore the packages exported with that executable or rebuild it. There is no
build-record fallback and no discovery of native runtime packages from the working directory.
Content-only mods still follow the working directory, player toggles and explicit --dev-mod paths;
a content package cannot shadow a compiled mod's id.
Move the executable, prepared resources and canonical package directory together. The copied
projects retain their flat manifest, crate, assets and data. The game's mod list and crash details
use its embedded inventory, including code-only mods. Display names and descriptions come from the
validated manifests; Controls bindings are declared through Registrar::register_key_mapping, not a separate NativeMod metadata hook.
native-mods.json is provenance and rebuild status, not game identity, authentication, announcements
or startup input. The launcher retains it for build/status displays. The separate horizon-server
supervisor may report the adjacent record as a provenance snapshot, not a query of the running engine.
Its SHA-256 values and revision describe the built binary's inputs, not the running mod set.
The launcher prepares jar data from canonical manifests as non-authoritative candidates; the engine
validates identity against its embedded inventory. Original-jar pack order and
whole-package refusal rules are in Original Java jar layers.
A bundle
The project and its .hmod have the same flat layout, with no mod-id directory level inside the archive:
my_mod/
horizon-mod.toml # the only manifest; mod identity and build pins
icon.png # optional launcher icon
assets/my_mod/
data/my_mod/
Cargo.toml # one crate; Cargo metadata is not mod identity
src/lib.rs # impl Hooks
src/frame.rs # optional injection callback module
Without Cargo.toml, it is a content mod. Code requires both Cargo.toml and [native];
inconsistent declarations are refused. The mod's id and version come only from horizon-mod.toml,
not Cargo's package name or version. An id is a plain [a-z0-9_]{1,64} name, also used as its resource namespace.
A code mod has no rust-toolchain.toml: the pinned Horizon commit decides the toolchain that builds it.
There is no input native/ or rust/ directory, horizon-native.json or hand-written horizon-mixin.toml.
The launcher writes the tool's manifest and stages the crate itself; those internal paths are not package layouts.
The manifest keeps the content fields, including the exact api = "0.2"
contract and capabilities = ["registries"]. A code mod adds:
[native]
game = "26.3"
horizon = "<exact 40-character lowercase Horizon commit>"
mixins = "<exact 40-character lowercase horizon-mixins commit>"
The placeholders above must be replaced by exact commits; the native template fills them in.
Unknown fields are refused. icon.png has no manifest key: when present it must be a valid,
nonzero square PNG, at most 512 × 512 pixels and 256 KiB encoded. It is optional for both content
and code mods; the scaffold does not create one.
Injections belong in the same manifest as top-level [[inject]] tables, with access = "api" or "internals". The decision ladder, the fields and the
forms are in the modding book: Injections.
Dimension keys
LevelRef::dimension returns a DimensionKey, a small Copy value that names the dimension
for as long as the game runs, custom dimensions included. Compare it with another key or with
DimensionKey::OVERWORLD, NETHER and END; level.dimension().id(host) gives the name
(minecraft:the_nether). On a client, host.level(name) gives the level it shows under the
name the server calls it, whether or not that is a vanilla dimension.
Dimension shape
LevelRef::dimension_shape reads the loaded server level's build range, logical height,
skylight and ceiling from its dimension shape. It accepts &dyn Host, including when the
level is parked, and returns None for a stale or unavailable level or on a client. It does
not load a level or allocate.
Dimension type
level.dimension_type(host) reads a level's dimension type (Level.dimensionType() and its
gameplay attributes) as a LevelDimensionType: the build range, coordinate_scale, ambient_light,
has_fixed_time, infiniburn, the monster spawn light range, the sky and the gameplay switches. A
level on a vanilla type returns that type's values, a registered dimension returns what its
DimensionSettings set, and a data pack's dimension type counts the same way. It returns None for
a stale handle, and on a client it reads the level that window shows; a client of another server
reads the Overworld's values for a level that server's mods added (the game itself plays that level
by the type the server sent).
Registering a dimension
register_dimension gives every world a level of the mod's own. Call it once, at startup, in
Hooks::register:
#[mod_api::horizon_mod]
impl Hooks for MyMod {
fn register(&self, r: &mut dyn Registrar, _: &RegistrationContext) {
r.register_dimension(
"pocket",
DimensionSettings { min_y: -128, height: 448, logical_height: 448, ..Default::default() },
);
}
}
The level is mymod:pocket. DimensionSettings sets the build height, has_skylight,
has_ceiling, the generator (minecraft:noise by default), its noise_settings and the level's
one biome; the default is the Overworld's shape and terrain over plains. It also sets the rest of
the dimension type the game reads: has_fixed_time, has_ender_dragon_fight, coordinate_scale,
ambient_light, infiniburn, the monster spawn light range and block light limit, the skybox,
the cardinal_light and the gameplay switches (water_evaporates, fast_lava,
respawn_anchor_works, nether_portal_spawns_piglin, piglins_zombify, snow_golem_melts,
can_start_raid, sky_light_level) and the bed rule (bed_can_sleep, bed_can_set_spawn,
bed_destroy_on_use, bed_destroy_on_leave: always, when_dark or never, and whether a bed
explodes or breaks; the same four with straw_bed_ for the straw bed). A field left at its default is the Overworld's, and the game
refuses a value out of range (a coordinate_scale outside 0.00001 to 30000000, an ambient_light that is not a number, an unknown
skybox, a bed rule that is none of the three) at startup with a message naming the mod. The
coordinate_scale is what execute in mymod:pocket scales a position by, and what a portal
between two levels scales by (DimensionType.getTeleportationScale); a position given to a
teleport call is taken as it is. The level opens, ticks, saves and closes as the
Nether and the End do, in dimensions/mymod/pocket/ of the world, and clients receive its
dimension type when they join. A client of a server takes the whole type, not only the build
range: the level's fluids, mobs, beds and time follow the type the server sent, as on the host,
whether or not the client has the mod that registered the level.
sky_color, fog_color (#rrggbb) and cloud_height set the sky overhead, the fog at the
horizon and the clouds' height; left as None they are the Overworld's. A client of a server draws a
level of a mod or a pack with the sky the server's type gives it, as the host does.
day_cycle picks the timelines and the clock the dimension type names: overworld (the day and moon
cycles, the villagers' schedule and the Overworld's clock, the default), nether (the schedule only,
no clock), end or none. It sets how the level's sky and light change over the day in the host's
world, whether monsters burn in daylight, and which clock /time reads there. level.dimension_type(host)
gives the timelines and default_clock back. The day time itself is the world's one clock.
The level's biomes are the one biome everywhere unless biome_source says otherwise:
BiomeSourceKind::Checkerboard alternates the biomes in cells 2^biome_scale chunks wide,
MultiNoise uses the Overworld's or the Nether's biomes (biome_preset) and End the End's islands.
Points places the mod's own biomes: biome_points lists a BiomePoint per biome, with an interval
((min, max), each from -2 to 2, a single value is (v, v)) for temperature, humidity, continentalness,
erosion, depth and weirdness, and an offset from 0 to 1. The biome whose point is nearest to the level's
climate at a position wins, as in a data pack's minecraft:multi_noise source with an inline biomes list;
the biomes can be the mod's own or vanilla's. A mod's points shape its own level only: placing a mod's biomes
among vanilla's in the Overworld or the Nether is a separate design (#2554). Only Fixed suits a chunk
generator of the mod's own, which writes terrain only.
A dimension in another mod's namespace, an empty name, a name that is not a folder of the world
(lowercase letters, digits and _ . - only, with / between segments that are not empty, . or
..), a vanilla level's id, an id registered twice, a build height the dimension type codec refuses
(min_y and height are multiples of 16, and the level stays between -2032 and 2031) and a
generator nobody registered stop the game at startup with a message naming the mod. The generator
may come from a mod that loads later.
A registered dimension is the level stem and dimension type a data pack would give, below every
data pack: a pack with data/mymod/dimension/pocket.json or data/mymod/dimension_type/pocket.json
replaces that half of it, in the worlds that have the pack.
register_dimension is the same call in a mod built to mod.wasm, as the settings are plain
data. A wasm mod's dimension can name only the game's own generator, minecraft:noise: a chunk
generator is code, and register_chunk_generator is native only.
Sending a player to a dimension
There is no portal to a registered level. A mod sends an entity there with
entity.teleport_to_level(host, dimension, pos, rotation) from a server hook, the call vanilla has
as Entity.teleportTo(ServerLevel, ..). It works for a player, a mob, a boat and a minecart (a
vehicle takes its passengers along).
fn run_mod_command(&self, host: &mut dyn ServerHost, event: &mut RunModCommandEvent<'_>) -> Flow {
let CommandSource::Player { player, .. } = event.source else {
return Flow::Continue;
};
let entity = player.entity();
entity.teleport_to_level(host, "mymod:pocket", [8.5, 100.0, 8.5], None);
Flow::Cancel
}
pos is in the destination's own coordinates: nothing scales it by the level's
coordinate_scale. A level nobody has entered opens first, and a player arrives after the current
tick, when player_teleported and player_changed_dimension report the move. The call
answers whether the move was taken, not whether the player arrived: a level that won't open, such
as one naming a generator nobody registered, logs an error and leaves the player where they were.
It is false for an unregistered dimension and for an entity that cannot change level (an item, an
orb, a projectile); a fake player stays where it is. Nothing builds a platform at the destination, so look for the
ground first, or place one.
Sea level
level.sea_level(host) reads Level.getSeaLevel: 63 in the Overworld, 32 in the Nether, 0 in the End. For a level of
the noise generator it is the sea_level of its noise settings, a data pack's too when it replaces them. A level
of a chunk generator the mod wrote has no noise settings to read: set DimensionSettings::sea_level to give it one
(a level of the noise generator keeps its noise settings'), else it is 63 (-63 for a superflat generator). It is the sea
level the game uses for the level: mob spawning and behaviour (drowned, turtles, phantoms), rain and snow, and maps.
Terrain and aquifers are built with the generator's own sea level, which for a noise level is the noise settings' and
which DimensionSettings::sea_level never changes: it only gives level.sea_level(host) a value for a generator that has
none, so it does not move where the water is.
Day clocks
Each level goes by its dimension type's default_clock: the Overworld by minecraft:overworld, the End by
minecraft:the_end, a level without one (the Nether) by the Overworld's. Every clock the world has met runs at its
own rate and keeps its own total, saved in world_clocks.dat, so a level with a clock of its own has its own day:
its sky, its spawning and its beds follow that clock, and /time in the level sets it.
level.clock_time(host) reads the level's own clock in ticks (None for a level without a default clock);
level.day_time(host) stays the Overworld's, whichever level is asked.
Which level a hook is in
The block-behaviour hooks (neighbor_changed, random_tick, crop_grow, block_changed and the rest) name their
level with a LevelDimension: Overworld, Nether, End, or Custom(key) for any other level, a mod's or a data
pack's. LevelDimension::key() gives the level's key (key.id() its namespace:path) and LevelDimension::of_level
goes back. Match it with a final Custom(_) arm. The game's own rules read the level's own dimension type for these
too (water that evaporates, fast lava, infiniburn, piglin portals), and player_respawn_position may send a player to
a Custom level.
Players, structures and chunk watchers
level.online_players(host) lists the real online players in that server level, including
when it is parked. Obtain a chunk with host.chunk(level, pos), then call
chunk.watchers(host) to ask which players were sent it and still have it inside their
view distance; fake players watch nothing.
level.find_nearest_structure(host, structure, StructureSearch::new(origin, radius)) searches
for a structure id or #tag and returns its structure and locate position. This is a
synchronous, /locate-cost search, not a cheap loaded-chunk lookup: cache results and avoid
per-tick use. The radius counts placement-cell rings (at most 100); stronghold rings are
checked regardless of radius. It does not load or generate chunks.
Saving a level
level.request_save(host) asks the server to save a level, as the autosave and /save-all do, with its
chunks on disk when it ends. The save runs at the start of the next server tick, not inside the call (a hook
may be running in the tick that is saving), and a level asked for twice before then is saved once. The call
answers whether the request was taken. level_save fires with the level's dimension once it was written; a
save that fails fires nothing and is logged.
Inside a hook, use level.request_save(host), not a synchronous save-all command.
While an entity pass or held-out row is borrowed, synchronous saving refuses with vanilla's
commands.save.failed feedback and a failed command result, without writing a partial snapshot.
Requested saves wait for a settled start-of-tick boundary.
Chunk generators
A chunk generator is a mod's code for a level's terrain: vanilla's ChunkGenerator, and
build_terrain its buildTerrain. Register it once, at startup, and name it from a dimension,
or from a data pack's level stem as "generator": {"type": "mymod:void"}:
use mod_api::{
BlockKey, ChunkAccess, ChunkGenerator, DimensionSettings, Hooks, Registrar,
RegistrationContext,
};
struct Floor {
stone: BlockKey,
}
impl ChunkGenerator for Floor {
fn build_terrain(&self, chunk: &mut dyn ChunkAccess) {
let stone = chunk.state(self.stone);
let [x, z] = [chunk.pos().x * 16, chunk.pos().z * 16];
for dx in 0..16 {
for dz in 0..16 {
chunk.set_block([x + dx, chunk.min_y(), z + dz], stone);
}
}
}
}
#[derive(Default)]
pub struct TerrainMod;
#[mod_api::horizon_mod]
impl Hooks for TerrainMod {
fn register(&self, registrar: &mut dyn Registrar, _: &RegistrationContext) {
let stone = registrar.block_key("minecraft:stone");
registrar.register_chunk_generator("floor", Floor { stone });
registrar.register_dimension(
"floor",
DimensionSettings {
generator: "mymod:floor".into(),
..Default::default()
},
);
}
}
There is no Host in world generation: chunks fill on the worker threads, so a generator sees only
the ChunkAccess of the chunk it fills, is Send + Sync, and keeps no mutable state, because two
workers can fill neighbouring chunks at once. The same seed and chunk give the same blocks.
ChunkAccess has the chunk's pos, the world's seed, the level's min_y and height, and
borrowed immutable metadata(). Resolve BlockKeys at registration, then use state or
state_with to construct API-owned BlockStates before the terrain-writing loop, never once per
placed block. set_block skips a position outside the chunk or build height and answers whether
it wrote. The chunk starts as air; invalid state ordinals are refused rather than written.
The generator gives terrain only. The level has no surface, carvers or features, and every chunk
has the stem's one biome. noise_settings means nothing to it.
A generator in another mod's namespace, an empty name, a name that is not a folder of the world (as
for a dimension), an id registered twice and minecraft:noise stop the game at startup with a
message naming the mod. ChunkGenerator and ChunkAccess are not yet bound by the generated wasm worker interface; a wasm
mod can register dimensions naming an already installed generator, but cannot register its own
chunk generator. Structure types use the existing generated worker binding instead.
A level stem naming a generator nobody registered, say after its mod was removed, is an error for whoever opens that level, not for the world: a teleport there fails and is logged, and a player saved there joins a server in the Overworld. Single player refuses to open a world whose player is saved in such a level.
Raid handles
Raid handles returned by LevelRef::raid, raid_at and raids resolve their
own loaded server level even while another dimension is in place. Raid reads,
omen-level writes and stop do not swap levels. Raider lists and living health
come from that level's mobs; raid ids are local to a level. A closed level, an
earlier world epoch or a client refuses the handle. Entity handles in a parked
raid's member list keep their identity; their ordinary entity operations still
require the entity's level to be in place.
Tool transformation previews
LevelRef::block_transform previews a tool against a loaded block in the server level in place.
Pass the live player, hand, stack, position and hit; the owned result describes the new state,
relative durability cost, sound, loot and particle. The call does not apply the result. It fires
block_tool_modification with ToolUse::simulated set to true: listeners must not change the
level during a simulation. A stale or unavailable player or level, unloaded position, invalid face,
or tool with no transformation returns None. The real action fires the same hook with
simulated set to false.
Named hook points
Implement catalog hooks as methods of mod_api::Hooks. An injection does not select a hook by
hook or hook_version: the manifest schema accepts [[inject]] with a target in at,
a crate-root listener in with, and the pinned function hash. See Injections.
Raw frame functions are native escape hatches, not methods on Hooks. For example, an
internals injection at horizon_mod_render::render::world_drawn names its listener with
with; use the current catalog hash and kind = "head". The launcher supplies the
checked raw listener signature, and the ordinary registry owns its priority and lifetime.
Teleport hooks
ender_pearl_teleport version 2 covers player-owned and mob-owned pearls. Its owner is an
EntityRef; listeners can cancel or redirect target. Player owners retain the
acting-player context and mutable fall damage. Mob owners take no pearl damage
and make no endermite roll. Pearl placement does not emit the command-only
entity_teleport notification. The pearl is consumed even when teleportation is canceled.
item_consumption_teleport version 2 covers players and foxes with the random-teleport
consume effect. Its consumer is a LivingRef, and consumed is the stack captured
before use, including count and components. Cancellation ends further teleport
attempts, not consumption. Fox callbacks run before that fox's living AI/movement
and before the next mob ticks, with the full server host. Listener changes to an
actor's item, position or eligibility survive rather than being overwritten by a
stale teleport decision. Command and Ender-entity teleport hooks are unchanged.
Both version-2 contracts replace version 1's player UUID/name fields: use owner
or consumer.entity() to address the actor, and the acting-player context for
player-only operations. Named injections must request hook_version = 2 and bind
the new parameter names; version 1 is not an alias for these contracts.
State flags on the living handles
host.living(entity) and host.mob(entity) make the handles of #7387's handle design, and
the state flags sit where vanilla keeps them: the ones LivingEntity owns read and write through LivingRef, the ones
Mob owns through MobRef. Reads take the shared host, writes the server one; a stale handle or a kind the game keeps
no such state for reads None and writes false, as every handle does.
Through LivingRef: the glide (is_gliding, gliding_ticks, stop_gliding), the sleep of a server player or a
villager (is_sleeping, sleeping_pos, start_sleeping, stop_sleeping, set_sleeping_pos, clear_sleeping_pos),
the jump of the player in place (is_jumping, set_is_jumping), climbing (is_climbing, the same onClimbable
movement uses, including the gliding exclusion for vines), the baby read (baby) and shield blocking
(is_blocking: LivingEntity.isBlocking, an item with blocks_attacks in use past its block delay, for players
and mobs). Server player sleep writes
work for the integrated owner, LAN guests and dedicated-server guests in the level in place, both inside and
outside their own turn; guests in another dimension and fake players refuse all four sleep writes without
mutation. A suspended outer actor is unavailable during a nested turn. Glide counters and stops follow the
same authoritative body across turns. These reads do not add client tracked-entity coverage.
start_sleeping requires a bed with an available sleep shape before changing any state (a villager also refuses an
occupied bed). It moves the sleeper onto that shape and occupies the bed. stop_sleeping stands the sleeper up
beside the bed with the normal wake animation, teleport and pose sync; the usual level pass frees a player's bed.
set_sleeping_pos and clear_sleeping_pos only change a player's sleeping position/pose, without moving them,
occupying a bed or playing a wake animation. The next entity sync publishes the changed pose and bed to clients.
Villagers use start_sleeping/stop_sleeping, not the raw player position setters.
EntityRef::portal_cooldown takes &dyn Host and reads the remaining portal cooldown in ticks from the
server's portal state: a player's own (the acting player inside its turn and other players outside it), and the
level's for mobs, items, experience orbs, boats, minecarts, primed TNT, falling blocks, arrows and the thrown items
and fireballs a chunk saves, fireworks and tridents; an ender pearl's comes from End gateways only. Zero means no
cooldown; None means a kind with no portal cooldown (armor stands, wind charges, the separately modelled dragon and
others), a client, or a stale, parked or otherwise unavailable handle. The read does not load levels.
MobRef::can_use_portal reads the existing server portal decision for a mob in the current level, including mounts and player riders. It takes &dyn Host: Some(false) means the mob is ineligible, while None means a client, stale or unavailable handle, parked mob, or the separately modelled dragon. It does not load a level.
EntityRef::leash_holder takes &dyn Host and reads the live lead of a mob or boat. LeashHolder::Unleashed
is a definite absence; Entity holds an existing entity handle for a player, mob or boat, and Knot holds
fence coordinates. None means an entity that takes no lead, that the entity or holder is unavailable, that the
level is parked, or that a saved holder has not resolved; this read neither loads a level nor changes the lead.
EntityRef::leash_to and leash_to_knot take &mut dyn ServerHost and hand the lead to a player, mob, boat or
fence as Leashable.setLeashedTo does: the entity lets go of any other holder and gets off its vehicle, and neither
canBeLeashed nor the distance is asked, so a holder too far away snaps the lead on the next tick. detach_leash
drops the lead as an item or removes it; a mob's removal fires entity_unleash.
Through MobRef: no_ai/set_no_ai (Mob.isNoAi and setNoAi: the mob stands, saved as NoAI) and
set_baby (Mob.setBaby through the canonical species-specific setup). Ageable mobs start at their species'
baby age (-48000 for a sniffer, otherwise -24000) or become adults at 0, including the age-boundary
attribute, navigation and AI changes. Their age is saved as Age where the kind saves one. Non-ageable babies,
such as zombies and piglins, keep their non-growing baby state instead. A species with no baby form refuses
set_baby without changing anything. Neither set_baby nor set_no_ai changes PersistenceRequired.
The persistence pair persistence_required/set_persistence_required reads and sets Mob.isPersistenceRequired;
vanilla gives that setter no way back. requires_custom_persistence is a separate despawn predicate
(Mob.requiresCustomPersistence and its species overrides): passengers, leashes and species-specific reasons
such as an enderman carrying a block. It is independent of PersistenceRequired and NoAI, not an alias for
either flag. The separately modeled dragon has no such predicate and answers None to this query, even though
its persistence flag can be read and set. A /summon and a spawn egg set their flags before the entity-join hook
runs, so a mod's own write in that hook is what stays.
Riding topology and resident handles
EntityRef names only an entity resident in the current level. A rejected Mob
passenger can remain a full object in a riding tree without becoming resident;
its saved UUID does not make another object's handle refer to it.
is_passenger and is_vehicle describe the actual relationships, including
retained off-level objects. Thus is_passenger can be Some(true) while
vehicle is None, and is_vehicle can be Some(true) while passengers
returns an empty list. Dying passengers keep their place until removed.
passengers returns only resident direct passengers in boarding order.
indirect_passengers walks the full tree depth-first, through retained objects,
and omits only unrepresentable handles: a resident grandchild below an off-level
passenger still appears. root_vehicle also traverses retained intermediaries,
but returns None if the actual root is off-level; it never returns a resident
descendant in its place. is_passenger_of_same_vehicle compares the actual roots,
so two resident entities can share an off-level root even though neither can
obtain its handle. Cyclic relationships have no root and return None.
controlling_passenger chooses the actual first passenger before checking whether
its handle can be returned. An off-level first passenger means None, not a later
resident rider. All these reads refuse stale input handles; none loads a retained
object into the level or exposes a handle for it.
During actual boat or cart destruction, Mob dismount notification still runs, but remaining edges are forcibly unlinked even when a listener cancels. This safe interim prevents references to dropped vehicles and preserves each Mob's resident or off-level classification and the frozen state of descendants. Retaining removed vehicles to preserve canceled relationships is deferred to #2446. Cancellation while the vehicle remains live, including underwater or activator ejection, still works.
Client effect interpolation
LivingRef::effect_blend_factor(host, effect, partial) reads the local player's client effect blend.
A newly blended ordinary effect reads 0 before its first effect tick, then interpolates to its
post-countdown target each client tick: 1 while active, 0 once expired. A finite effect that expires
on its first tick never rises; an infinite effect still has the initial interpolation.
An unblended addition starts at its target immediately; replacing an existing effect keeps its blend.
Darkness and nausea use their existing longer fades. Pass the render partial tick unchanged: the read
does not clamp it. A missing effect reads Some(0.0); an unknown effect, stale handle or entity other
than the local player reads None. Other visible entities' effect blends are not yet supported.
The build's warnings
Before it builds, the launcher validates injection declarations and reports their slot/source routes
in native/build.log. Invalid or stale declarations stop the build; see Injections.
A mod's own ingredient kinds, matching at craft time
The load-time list only names items. Matching a concrete stack — its components, usually —
uses Hooks::ingredient_test(context, event): the ingredient's test(host, stack) reaches it with the loop context of the side host runs on, the
kind and NBT compound the load-time hook saw, the stack being matched and the verdict, already true
(the list named the item). Set accept to false to refuse the stack (ICustomIngredient.test).
The hook may be called many times a craft; the answer must be the same for the same stack.
data is an API Compound, not a JSON object. Match its fields as typed Tag
variants (Int, Long, List, Compound, and so on); the game converts the
recipe metadata once at load with vanilla JsonOps-to-NbtOps rules. Numeric JSON
arrays remain lists. Nested null refuses the ingredient and fails the recipe
like an unclaimed kind; matching retains the same compound without reconversion.
For an end-to-end mod test, use horizon-machine-sample
and implement Hooks::ingredient / Hooks::ingredient_test with the typed
compound contract; no JSON signature is needed in a mod.
The mod's crate
The root Cargo.toml and src/ form the mod's crate. It depends on horizon-mod-api (lib
mod_api), never directly on the game. The launcher supplies the pinned SDK and declared
mod dependencies.
Registry dependencies belong to the mod. A mod may use crates.io packages that the game does
not use, or different versions of packages the game uses. Each package must have an exact version
and SHA-256 checksum in the mod's registry dependency lock, stored as Cargo.lock beside its
Cargo.toml. Keep this file in version control and include it in the mod package. Changing it
changes the native build's cache identity.
Generate it explicitly after choosing your registry dependencies:
horizon-launcher native-lock path/to/mod
# To deliberately resolve fresh pins and replace an existing lock:
horizon-launcher native-lock path/to/mod --update-lock
This is the registry dependency lock consumed by launcher staging, not a promise that ordinary
Cargo can resolve the original pinned SDK declaration offline. The command creates an inert copy
of the manifests, removes only SDK dependencies naming the exact pinned Horizon repository and
revision, and resolves registry metadata. It does not copy or execute mod sources, build scripts
or proc macros. It does not modify the original manifests or Cargo configuration. Existing pins
are not replaced without --update-lock; a refused input leaves the existing lock untouched.
A starter that uses only the launcher-owned SDK does not need registry pins until it adds a
registry dependency.
Builds consume an existing lock; they never silently generate it or repin a missing dependency. The launcher may resolve registry index metadata online using an inert manifest whose requirements are exact pins. Before fetching crate archives, every resulting identity and checksum must match a supplied lock. Cargo checks downloaded archives against those checksums. Only after this fetch does the launcher resolve the actual staged workspace offline.
The game's locked packages, checksums and dependency graph stay immutable. A different mod-owned version may coexist where Cargo permits it. If a requested version would replace a game version, the build refuses it rather than updating the game. A native library with conflicting providers cannot be silently substituted. Feature selections must not introduce unpinned packages or change the game's graph.
Git dependencies other than the proven launcher-owned SDK declaration, alternate registries,
paths escaping the mod, linked dependency paths, [patch], [replace], and mod-owned Cargo source
configuration are refused. Local crates inside the mod are traversed without copying their code
into the registry resolver. Registry dependencies remain native code: their build scripts and
proc macros execute during compilation, and the resulting mod runs with your rights. Trust their
authors as well as the mod's author.
Before compiling these dependencies, the launcher shows how many registry crates the mod adds and names the reachable build scripts and proc macros. Declining stops before any mod dependency is compiled. The existing native-code permission is also required; downloading a checksummed crate does not make its code trusted.
On the optimized prebuilt route, Cargo compiles the mod's registry graph and the final wrapper against immutable SDK binaries. Registry dependencies never cause an automatic source rebuild. The SDK must include its locked graph and runtime native-library inventory; older SDKs without that information refuse registry builds. The launcher preserves the compiler/cache wrapper chain and uses its pinned Rust compiler.
SDK crate identities are not interchangeable. A registry-built copy of a trait or type is not
the SDK's copy, even at the same version. For example, passing an SDK value implementing the SDK's
serde::Serialize to a registry-built serde_json can fail with Rust E0277. The launcher reports
that boundary; it does not replace either library or rebuild the game to hide it. Keep private
registry types behind your mod's API-facing values.
Runtime native libraries emitted by registry builds are collected only from managed build and Cargo directories. Host-only build tools and proc macros are not packaged into the game. Conflicting native providers or output filenames, escaping outputs, relative native search paths, and bundled framework outputs are refused explicitly. System libraries remain system dependencies. These restrictions do not trigger a source fallback.
Conditional data
Server JSON files can carry a neoforge:conditions array. Supported readers are
recipes, loot tables, item modifiers, predicates, enchantments, jukebox songs,
instruments, damage types, advancements, synchronized registry JSON, world-table
tags (including loot, damage, synchronized registry and function tags), and native item/block data maps.
Every condition must hold; a false or unsupported condition omits the file.
Ordinary JSON resources select the winning pack file first, then evaluate its
conditions before decoding the data. If the winner is omitted, a lower pack's
version does not return. Tags and data maps evaluate each layer before merging,
replace, or remove, so an omitted layer leaves lower entries intact. A damage
registry is frozen at world open; reloading reevaluates its tags, not its type
definitions. Generated item default components retain their existing prototype
lifetime and are not conditional data files.
neoforge:mod_loaded asks the owning resource generation's captured enabled
server-mod IDs, including active code-only native and WebAssembly mods, plus
minecraft always. Client-only, disabled and refused packages are absent;
resource namespaces are not mod IDs. Other supported forms are neoforge:true,
neoforge:not (value), and neoforge:and / neoforge:or (values). An unknown
kind anywhere in these trees omits the file, including beneath not or or.
A file without the conditions array loads normally. Successful reloads publish
changed enabled IDs together with the data generation, including command predicates
and item modifiers; retained readers keep
their old snapshot. Recipe selection and its condition evaluator are unchanged.
Reading recipe rules
recipes_of_type returns the loaded recipes after pack overrides. Shaped,
shapeless and cooking recipes expose their resolved ingredients and fixed
results. Stonecutting, smithing and special crafting recipes expose a typed
RecipeRule instead, including resolved ingredient roles. A smithing or
special rule's result depends on its inputs: its template is not a fixed
stack. Use LevelRef::recipe_for for the actual output and remainders of a
crafting grid. Smithing transform's template and addition ingredients can be
absent, requiring empty slots. Trim patterns are either registry references or
inline definitions. Result templates retain their count and component patch;
stonecutting exposes the complete fixed output. A mod-defined recipe type
retains its original json. Look up recipes again after a data reload.
Recipe filters
A content mod or a data pack can remove recipes, or swap one of their inputs or outputs, without knowing
their ids: a file data/<ns>/recipe_filter/<name>.json holds {"filters": [...]}. Each filter has match
keys, all of which must hold (a filter with none matches every recipe), and exactly one action:
| Key | Meaning |
|---|---|
id | the recipe id; * matches any run of characters (minecraft:*_planks) |
type | the recipe's type, with the same * (mymod:*) |
input | an item id or #tag among the recipe's inputs |
output | an item id among its outputs (anything under result, results, output or outputs) |
remove | true: the recipe is not loaded |
replace_input | {"from": ..., "to": ...}: every input that is from becomes to |
replace_output | the same, for the outputs |
{"filters": [
{"id": "minecraft:*_planks", "remove": true},
{"type": "mymod:*", "replace_input": {"from": "minecraft:iron_ingot", "to": "#c:ingots/iron"}}
]}
Filters run on each recipe after the packs are merged and its load conditions held, so they see the final
set and reach every recipe type, mods' own included: they work on the recipe's JSON, every string outside
type, group, category and the conditions (a {"tag": ...} object's tag reads as #tag). Every recipe
loader applies them: crafting, the stations, brewing, the recipe book and what players are sent. Files apply
in id order and each file's filters in order; a pack's file replaces a file of the same id, as any data file
does. A filter with an unknown key or not exactly one action is skipped with a warning, so a typo never
matches every recipe.
A mod's own enchantment effect kinds
A minecraft:post_attack entry with enchanted attacker and affected victim whose effect.type is in a
namespace outside minecraft: reaches
native_enchant_effect(enchantment, kind, data, level, victim) when a player's weapon hits a mob and the entry's
requirements pass. victim is the mob's id, and the acting player is the one holding the weapon. A bare type
name is minecraft's own. Other components and pairs, a hit on a player, and an entry inside minecraft:all_of
aren't told yet. An unclaimed kind does nothing. event.data borrows a Tag retained at definition load, not JSON; invalid payloads refuse the definition.
A mod's own consume effects
A consumable's on_consume_effects entry of a type in a namespace of its own reaches native_consume_effect(kind, data) when the item is consumed, on the server
thread, for the acting player. The kind and a borrowed Tag converted from the definition at load are there; the game's own effects,
sounds, teleports and drops are open to the hook. An unclaimed kind does nothing.
Consumable and death protection payloads are decoded once for item definitions and stack component patches; the event borrows the retained payload. Invalid payloads refuse definition loading rather than failing when consumed.
A mod's own loot functions and conditions
A loot table's function or condition that names a type in a namespace outside minecraft: reaches native_loot_function(kind, data, ctx, stack) or
native_loot_condition(kind, data, ctx, holds) when the table rolls. The function changes the stack; the
condition says whether it holds. The context brings the entity, the damage, the player who killed it, the
tool, the block, the luck, the biome and a seed from the roll's random. An unclaimed function leaves the
stack, an unclaimed condition fails, as unknown kinds do; a bare type name is minecraft's own.
The tables the items engine rolls (block drops, entity drops, loot_table, interact_loot and the
built-in tables) answer both hooks, with the tool and the block in the context. The function hook there
gets an owned API LootStack: its resolved ItemKey, signed intermediate count, damage, enchantments
(a book's stored ones), potion, ominous bottle amplifier, stew effects and ordered NBT component patches.
Each patch is (String, Option<Tag>); None removes a component, and repeated IDs apply in order.
Unknown NBT values retain their exact type, including byte arrays and bytes. Names are API Text trees.
The context is an owned LootContext, including entity equipment and deferred exploration-map facts.
Conversion runs only after the subscription guard. Writeback validates the entire candidate before replacing
the engine roll; an invalid candidate leaves the roll unchanged. Intermediate counts can be non-positive:
these values are not occupied inventory stacks. A function that asks for a map's structure search (map/exploration) changes nothing there: the
items engine has no level to search. In the items engine's context the entity, damage, player and
biome fields are None and luck is 0; in the main loot engine's context the tool and block fields
are None.
Both engines' contexts carry place, where the roll happens: origin (a broken block's centre, a dead mob's
position), block_entity (the broken block's entity as it was before the break, saved with its id, position and
Items) and level (the dimension, the game time, the time of day, the rain and the thunder, for conditions like
vanilla's time_check and weather_check). The game sets it for a block a player or an explosion breaks and for a
mob's death loot; on other rolls place is None.
Recipe book category declarations (first slice of #5501)
The recipe_book_categories hook declares recipe book categories. Trait mods import
RecipeBookCategory from mod_api and append declarations in
Hooks::recipe_book_categories:
event.categories.push(RecipeBookCategory {
id: "mymod:crushing".into(),
recipe_type: "mymod:crusher".into(),
icon: "mymod:crusher".into(),
});
Mixin bundles use native_recipe_book_categories(categories) at the same hook
point. host.recipe_book_category("mymod:crusher") (the mixin helper is
recipe_book_category) returns the validated declaration, including its
icon, or None. It does not grant recipes. Declarations are requested lazily at
first lookup, cached for that recipe table, and invalidated on a new session,
server recipe-content replacement or server /reload. No lookup means no hook
call or per-frame work. A client validates against its own custom recipes unless
native_datapack_sync has supplied the server's type.
Every field must be a fully namespaced resource id. Category ids and recipe types
must be outside minecraft:; the type must occur in the current custom recipe
table. The icon must name a registered, non-air item. Invalid ids, unknown types,
missing/unknown icons, and duplicate category ids or recipe types are logged and
skipped, never panicked on. The first valid declaration wins each id and type;
an invalid declaration does not reserve either. This slice supports one category
per type, not per-recipe category selectors.
Not yet book UI support: custom recipes still have no book displays or unlock/display-id synchronization. This slice deliberately leaves vanilla tabs, search, layouts and unlock behavior unchanged. Adding custom tabs, displaying and searching recipes there, and unlocking them like vanilla remain follow-up work for #5501; a declaration alone will not make a recipe visible.
For proposed book identities and saved open/filter flags, see the custom recipe-book settings design.
A mod's own ingredient kinds
A recipe's ingredient that names a type in a namespace outside minecraft: reaches
Hooks::ingredient(context, event) at load time. Fill event.items with the item ids the
ingredient matches (item ids only: components are not checked; a match-time hook follows
separately). Duplicates are removed keeping first-seen order. The hook gets the DataLoadContext of the load that parses the recipes and hears each occurrence of the kind once per load, nested ones and ones under any slot a recipe reads an ingredient from included, before the load's tables parse the recipes: the client's own recipe load has origin() ClientPreload, the server's ServerDataLoad. A server loads its recipes once however many tables and copies read them (its recipe tables, the stations and the copy synchronised to guests, on a dedicated server too), and a /reload asks again once; a client that only joins a remote server is never told ServerDataLoad. The answer must be the same every time. An unclaimed kind leaves the list empty and the recipe fails, as an unknown kind does.
data is an API Compound, not a JSON object. Match its fields as typed Tag
variants (Int, Long, List, Compound, and so on); the game converts the
recipe metadata once at load with vanilla JsonOps-to-NbtOps rules. Numeric JSON
arrays remain lists. Nested null refuses the ingredient and fails the recipe
like an unclaimed kind; matching retains the same compound without reconversion.
For an end-to-end mod test, use horizon-machine-sample
and implement Hooks::ingredient / Hooks::ingredient_test with the typed
compound contract; no JSON signature is needed in a mod.
Structure types
A structure type is a mod's code where a data pack names one: a structure JSON whose "type" is
"mymod:tower" instead of a vanilla type generates by the mod's StructureType (vanilla's
StructureType, whose find_generation_point returns the start and whose pieces are
StructurePieces, loaded by the StructurePieceType that load_piece is — NeoForge registers
these in the registries STRUCTURE_TYPE and STRUCTURE_PIECE). Register it once, at startup, in
Hooks::register; the id is in the mod's namespace, and a duplicate id, an id in another mod's
namespace or an empty name stops the game at startup with a message naming the mod:
use mod_api::{
BlockKey, BlockMetadata, BlockState, BoundingBox, Compound, FoundStructure,
GenerationContext, Hooks, PieceContext, Registrar, RegistrationContext, StateConstraint,
StructureError, StructurePiece, StructureType, Tag,
};
use std::sync::Arc;
const MATERIAL: &str = "minecraft:oak_log";
struct Tower {
material: BlockKey,
}
#[derive(Debug)]
struct Pillar {
min: [i32; 3],
max: [i32; 3],
state: BlockState,
axis: String,
}
impl StructureType for Tower {
fn find_generation_point(
&self,
context: &mut dyn GenerationContext,
) -> Option<FoundStructure> {
let state = BlockState::of(
context.metadata(),
self.material,
&[StateConstraint::new("axis", "y")],
)
.ok()?;
let min = [
context.chunk().x * 16,
context.sea_level(),
context.chunk().z * 16,
];
Some(FoundStructure {
position: min,
pieces: vec![Arc::new(Pillar {
min,
max: [min[0], min[1] + 4, min[2]],
state,
axis: "y".into(),
})],
})
}
fn load_piece(
&self,
metadata: &dyn BlockMetadata,
tag: &Compound,
) -> Result<Arc<dyn StructurePiece>, StructureError> {
let block = saved_text(tag, "block")?;
if metadata
.name(self.material)
.map_err(|error| StructureError(format!("saved material: {error:?}")))?
!= block
{
return Err(StructureError(
"saved block does not match this pillar's material".into(),
));
}
let axis = saved_text(tag, "axis")?;
let state = BlockState::of(
metadata,
self.material,
&[StateConstraint::new("axis", &axis)],
)
.map_err(|error| StructureError(format!("saved block properties: {error:?}")))?;
Ok(Arc::new(Pillar {
min: saved_position(tag, "min")?,
max: saved_position(tag, "max")?,
state,
axis,
}))
}
}
impl StructurePiece for Pillar {
fn bounding_box(&self) -> BoundingBox {
BoundingBox::new(self.min, self.max)
}
fn post_process(&self, context: &mut dyn PieceContext) -> Result<(), StructureError> {
for y in self.min[1]..=self.max[1] {
context.set_block([self.min[0], y, self.min[2]], self.state);
}
Ok(())
}
fn add_additional_save_data(&self, tag: &mut Compound) {
tag.insert("min".into(), Tag::IntArray(self.min.to_vec()));
tag.insert("max".into(), Tag::IntArray(self.max.to_vec()));
tag.insert("block".into(), Tag::String(MATERIAL.into()));
tag.insert("axis".into(), Tag::String(self.axis.as_str().into()));
}
}
fn saved_text(tag: &Compound, key: &str) -> Result<String, StructureError> {
match tag.get(&key.into()) {
Some(Tag::String(value)) => value
.to_string()
.map_err(|error| StructureError(error.to_string())),
_ => Err(StructureError(format!("missing or invalid saved {key}"))),
}
}
fn saved_position(tag: &Compound, key: &str) -> Result<[i32; 3], StructureError> {
match tag.get(&key.into()) {
Some(Tag::IntArray(values)) if values.len() == 3 => {
Ok([values[0], values[1], values[2]])
}
_ => Err(StructureError(format!("missing or invalid saved {key}"))),
}
}
#[derive(Default)]
pub struct StructureMod;
#[mod_api::horizon_mod]
impl Hooks for StructureMod {
fn register(&self, registrar: &mut dyn Registrar, _: &RegistrationContext) {
let material = registrar.block_key(MATERIAL);
registrar.register_structure_type("tower", Tower { material });
}
}
There is no Host in world generation: starts build on worker threads and pieces place per chunk,
so the type and its pieces see only two small contexts, Send + Sync and deterministic. The same
seed gives the same pieces, and a type keeps no mutable state, because two workers can build
neighbouring chunks at once.
StructureType::find_generation_point(&self, context: &mut dyn GenerationContext) answers
FoundStructure { position, pieces } (each piece an Arc<dyn StructurePiece>) or None for "no start in this chunk". The
GenerationContext is vanilla's GenerationContext over one chunk: the seed, the chunk, the
random seeded as vanilla seeds it for the chunk (setLargeFeatureSeed), the height and
base-column queries, opaque noise-biome identities and the valid-biome test, borrowed immutable
metadata(), and the structure's config as a shared Arc<Tag> (context.config()). JSON converts
once when the definition loads or reloads, not once per start. Nested nulls refuse at load. Both contexts are traits and the random is a
StructureRandom (next_int_bound, next_long, next_float, next_double, next_boolean),
so a type built to mod.wasm runs the same code. Generated worker calls take the dependency
group's store lock and per-call fuel or epoch budget; a trap stops that group with a typed refusal. Everything around the type stays the game's: the structure set's
spacing, the structure's biomes and terrain_adaptation are placed by the engine, exactly as
for a fortress.
Each piece is a StructurePiece (Debug + Send + Sync): bounding_box, post_process and
add_additional_save_data are required. After all pieces of the start were placed, the type's own
after_place (vanilla's Structure.afterPlace) runs once per chunk with every piece's current
box; it defaults to doing nothing. The
PieceContext of post_process and after_place is the chunk being placed: set_block writes a
block and drops a position outside the writable box silently, as vanilla's
StructurePiece.placeBlock skips it; block and height read, state/state_with resolve a
a registered BlockKey into an API-owned BlockState, random is the piece's RandomSource as a
StructureRandom, and metadata().name(state.block()) borrows its canonical block name.
solid_render asks whether
a state draws as a solid block, and entity queues an entity from its saved NBT — its id, Pos
and fields — which the generating chunk holds and the game finishes as a structure mob's
(Mob.finalizeSpawn with STRUCTURE when the NBT keeps the "horizon:finalize" byte a structure
template's entities carry). block_entity places a block entity from its NBT.
Saving goes through the engine's StructurePiece.createTag — the type id, BB, O, GD —
around the piece's add_additional_save_data. When the chunk loads back, the type's load_piece
(the StructurePieceType loader) rebuilds the piece exactly as it was saved; there is no
regeneration from the seed. load_piece(metadata, tag) returns a typed StructureError for invalid
saved block names, properties or piece data. Its metadata is the same retained snapshot the worker
contexts borrow. Resolve states once in piece construction or load, as the example does; keep
canonical names and properties in saved data, never process-local ordinals. post_process then
writes retained states without per-block name lookups or allocation.
Open question — the saved piece's id is the structure type's, not a piece type's. Every piece
of a type saves with that type's id, the STRUCTURE_TYPE id; Horizon has no separate
STRUCTURE_PIECE registry, so there is no per-piece-kind id in the tag. A mod whose type has
several kinds of pieces tells them apart in load_piece by its own saved data (a tag field its
add_additional_save_data writes). The O (orientation) and GD (genDepth) fields are written
as -1 and 0 — the adapter has no orientation or depth to record — so a piece must not read
them back as meaningful.
The pieces ride the one piece mechanism the game has: they are legacy pieces behind an adapter, so
the start cache, the cross-chunk placement, the beardifier and /locate structure treat a tower
piece like a fortress piece's. StructureRegistry reads the registered types before it falls to
Unsupported, so a data pack structure whose "type" no installed mod registers places nothing,
exactly as before.
Fluid ingredients
A mod's own recipe type that drinks from a tank reads its fluid inputs as a FluidIngredient, the
fluid analogue of an item ingredient: FluidIngredient::from_json(json, context) parses a fluid
("minecraft:water"), a #tag (the fluid tags the load's DataLoadContext holds), a list of
them, or NeoForge's compound, intersection and difference shapes, with the same walk the item
ingredient parses its own JSON with — call it where a data load hook hands you the context, as the
ingredient hook does while the game resolves recipes. test(&FluidStack) asks whether a tank's
fluid answers, and fluids() lists the fluids it accepts, in tag order, for a recipe book. A kind
outside the standard shapes fails the parse: fluids have no custom ingredient hook, and components
are not checked — any stack of a named fluid answers.
SizedFluidIngredient adds the amount, as NeoForge's SizedFluidIngredient:
{"ingredient": <fluid ingredient>, "amount": 500} in millibuckets, "amount" defaulting to a
bucket (1000) and always positive. Its test(&FluidStack) asks that the stack matches the
ingredient and carries at least that much.
Data files
A mod has one top-level data/ tree and one assets/ tree, with the same namespace and
content checks as a content mod. They are normal pack resources, not a second native-only data folder.
Server data reload listeners register through
Hooks::add_server_reload_listeners. Their ServerReloadContext lists namespaced IDs and reads the
winning bytes from selected datapacks and the mod's own data/. Parse once in prepare and publish in
apply, not on the tick. Server listener applies run before data_pack_files. A later refusal
keeps the old engine tables and Parts, but cannot undo effects of listeners or hooks that already
ran. Contributions share a list, so a failed file cannot be attributed to a particular mod.
The original pack trees remain separate from the launcher's code-only horizon-mixins input.
Client-side asset reads use client asset reload listeners; server
listeners cannot read assets/. Packaged assets also participate in the game's normal resource
packs.
Outside a listener, host.resource(pack_type, &identifier) reads one file from the selected
stack: PackType::ServerData takes mymod:machines/furnace.json from data/, and
PackType::ClientResources takes mymod:models/block/furnace.json from assets/. As with
ResourceManager.getResource, the highest selected pack that supplies the file wins; filters,
a world's datapacks and a mod's own root data all apply. The answer is the winning bytes, or
None for a missing, unreadable or over-64-MiB file. Use a reload listener for a whole folder
or for parsing at reload time; a single read does not reload packs. Directory-pack
symlinks follow vanilla pack behavior: linked resource files are read through the pack.
Code-only bytes belong inside the crate: use Cargo's ordinary build.rs to generate a table in
OUT_DIR, then compile it with include_bytes!(concat!(env!("OUT_DIR"), "/table.bin")).
They are not runtime pack resources and do not participate in resource reloads.
What a native bundle holds. The one manifest, optional icon, root Cargo.toml and build.rs,
.rs and .toml files under src/, assets/ and data/, and root text files (README*, LICENSE*,
LICENCE*, COPYING*, NOTICE*). Source and resource size limits are checked separately;
anything else refuses the archive (the manifest).
One .hmod for content and code. Without root Cargo.toml, nothing compiles. With it and [native],
the launcher builds the code and the game loads the same package's content through horizon_mod_package::Content.
Both kinds use horizon-mod.toml for their identity. Executable mod.wasm files are unsupported.
What the launcher does
Developing a mod? Pass --profile developer to horizon-launcher native-build.
The choice is remembered for that instance, not shared with other instances.
Developer builds the game crate unoptimized for iteration rather than game performance. Use it while developing, not for playing. A needed build picker starts at
Balanced until a choice is saved; automatic prebuilt builds still use the release profile.
In the window: install a native mod from the Mods page, into the instance picked on the left ("Installs into
{instance}", shown even with one instance). The first one shows a trust notice once (native mods aren't sandboxed;
a build may download Rust and other build tools); mods install prints it too. Each
installed native mod's row says "Installed in {instance}" and has Open folder (<instance>/horizon-native-mods/).
The installed list has a Native build of {instance} row for the instance picked on the left, never another's:
- A badge: Not built (the version it plays isn't a
native-*build), Needs rebuild (themodsin itsnative-mods.jsondiffer from those turned on, by id and version) or Up to date, with Build for the first two; - For a native build, "Plays native-…, built on Horizon {version}" (the
baseof itsnative-mods.json);
The instance list on the left shows such an instance as "Native build · Nightly 10-28" (its base). The launcher names
the mods, warns in red, and builds only from "I trust these mods: build"; the row shows each step and the error if
one fails, on the instance being built only; one build runs at a time. From the command line:
horizon-launcher native-build [FOLDER] [--instance NAME] [--update] --trust-native-code. FOLDER is a bundle, an
unpacked .hmod, or a folder of them; without it, the native mods turned on in the instance. It is not an MCP tool:
trusting native code is the player's decision, never an agent's.
-
Rust. The launcher's own toolchain (
rust_toolchain): Rust 1.99.1's officialrustc,cargoandrust-std(andrust-mingwon Windows), each pinned by SHA-256, downloaded once from static.rust-lang.org into<launcher data>/rust/, with its own Cargo home<launcher data>/cargo/. No rustup, nothing on the system; the player's own Rust is never used. It ships for macOS (Apple Silicon and Intel), Linux x86-64 and Windows x64. Windows uses thewindows-gnuhost, which links without Visual Studio.rust-mingwonly links, so on Windows the toolchain also brings MinGW-w64 (rust_toolchain::MINGW: WinLibs' GCC 16.2.0 on MinGW-w64 14.0.0, MSVCRT likerust-mingw, one zip pinned by SHA-256, itsmingw64/without the documentation) on the build'sPATH:ringandhidapicompile C, andwindows-sys'raw-dylibimports needdlltooland the assembler it runs. The linker is the system's on macOS (the Command Line Tools: the launcher asks forxcode-select --install) and Linux (cc). On Linux the game also builds against the system's ALSA (alsa-sys) and libudev (libudev-sys,hidapi), found withpkg-config, whose development files desktop distributions don't install. Before downloading anything the launcher checks forcc, the C library's headers (cccompiles an include ofstdint.h),pkg-config,alsaandlibudevand names what is missing, with the install command for the distribution in/etc/os-release(Debian, Ubuntu and Mint:sudo apt install build-essential pkg-config libasound2-dev libudev-dev). SteamOS has GCC without the C library's headers, and a read-only system: native builds don't work there yet. On Windows, Smart App Control can block newly compiled build scripts or toolchain programs withos error 4551. The launcher adds advice when it sees that error; it does not check the setting before a build. To build after a block, Windows Security > App & browser control > Smart App Control settings offers an off switch. On some Windows versions, turning it back on requires reinstalling Windows. Content mods need no build and work with Smart App Control on.horizon-launcher rust status|install|remove. -
horizon-mixins, at the exact commit the mods name (
[native] mixinsinhorizon-mod.toml), built once with that cargo (cargo install --locked --git … --rev …) intonative/tools/<commit>/. -
The source of the pinned commit: GitHub's archive of it, once per commit (
native/source/<commit>/, about 63 MB unpacked), with the commit in.horizon-revision. Never the player's own checkout. Links in the archive are left out: the tool refuses them and nothing needs them to build. -
Stage. The projects are copied first, so the build and its key use that copy whatever happens to the folder meanwhile. Compiled slot listeners register without a source splice. For source-routed internals declarations the launcher generates private slots and source adapters before compilation. It stages the mod crates and generated executable, copying only changed files into
native/stage/, so Cargo, which goes by file times, recompiles only the crates the mods touch. Each file written gets the current time, never the archive's (the commit's) or the copy's original: on Windows and macOS a copy keeps it, and a file older than the last build reads as already compiled, so switching Nightlies linked the previous one's crates (horizon-rust/horizon#10421). A stage from before that fix is written again once (native/stage.stampedmarks it). Registry dependencies are checked against Horizon'sCargo.lockbefore Cargo resolves the staged workspace (The mod's crate). -
Compile:
cargo build --locked --profile <fast|release> [--config ...] [--features horizon-client/mods] -p horizon-native-game, intonative/target/, with one of three build modes:Mode Cargo profile Use optimizedrelease, the release's own (game-build.yml: thin LTO, one codegen unit)The mode for playing: mods that need nothing but the mod API compile against the prebuilt game, built with this profile; any other set builds the whole game from source with it balanced(initial picker choice)fast, the workspace's own:codegen-units = 16, incremental, andlto = false, Cargo's default, which is thin-local LTO across one crate's codegen units, none across cratesAlways builds the game on this computer: a rebuild recompiles and links only what changed, and the game plays a little slower than Optimized developerfastwithhorizon-clientat opt-level 0 (--config profile.fast.package.horizon-client.opt-level=0)Developing a mod: the quickest rebuild after an edit, but the game runs about twice as slow, so iterate with it rather than play Only Optimized uses the prebuilt game. The Mods page shows how each native mod reaches the game: a bolt for the prebuilt game, a hammer for a full game build (its menu names the reason), a check when the game already has the mod. A build under way shows its stage and can be cancelled.
devlowers onlyhorizon-client, so an edit in another workspace crate (horizon-client-lib,-render,-world) still compiles at opt-level 3, as with Balanced. The launcher sets it with--config(native_mods::Profile), so the workspace's own[profile.fast]builds stay as they are. The Mods page's "Build mode" card ornative-build --profile ...picks and remembers the instance's choice. Without a source-build reason or explicit CLI choice, installed mods use the automatic prebuilt route; a saved preference is applied only when a picker is needed.native-mods.jsonrecords the effective profile.Each profile is in the build's key, so their builds never mix and may all stay.
balancedanddevelopersharenative/target/fast/: every crate buthorizon-clientis built with the same settings, so switching between them recompiles onlyhorizon-client, and Cargo keeps both builds of it, so switching back finds the other still there.optimizedhas its own folder (native/target/release/). The first build in a folder builds every crate once; later builds reuse them. Cargo downloads the locked crates from crates.io (Horizon has no git dependencies). The compiler and the horizon-mixins build run at the lowest priority (rust_toolchain::low_priority: nice 19 and the idle I/O class on Linux, nice 19 on macOS, below-normal priority on Windows), so the desktop and a game in play stay smooth; on an otherwise idle computer they still use every core. The log isnative/build.log; a failure shows its last lines (a horizon-mixins diagnostic in one line). -
Install: a new version,
native-<commit>-<key>, a copy of the starting version's folder (resources included, cloned on APFS, Btrfs and ReFS) with the built game in place of its own, and anative-mods.jsonsaying which release or Nightly it started from (base) and which commit, mods, horizon-mixins and Rust made it. Canonical mod projects are copied intohorizon-native-mods/<id>/beside the executable. The game lists its embedded inventory in a crash report's details (Native Mods: id version, …; they stay in the local report). The instance the mods are installed in plays it, the default Horizon instance included: the build pins its version, as picking a version does. Updates of the launcher's version leave a pinned instance where it is, and their clean-up never removes a native build. The Native mods instance keeps its worlds and its build.
Build log
The full log of a build is native/build.log, beside the build's own folders under the launcher's data
folder (native_mods::log_path). Each new attempt replaces it. Compiler output and the final result
stay in the log, including failures before compilation or while selecting or exporting the built game.
The launcher has a Build log action at the top right. It is there whenever a log exists, also right after the launcher starts, and it always opens the latest build's log: the next build replaces it. The panel shows a bounded preview of the end of the log and says when it truncated; Copy log copies the whole file, Open full log opens it outside the launcher, Show in folder reveals it, and Refresh reads it again (also after a build finishes). The panel is a snapshot read on open and refresh, not a live stream: a running build's later output appears only on the next refresh. Nothing is uploaded; the log stays on the player's computer.
Rebuilds stay on the instance's Horizon
A build starts from the version the instance plays (native_mods::base_for). When that is a native build, the
next one compiles the same commit (its native-mods.json's horizon) from the same game files, unless a mod pins
another, so a rebuild after a mod changes or is switched off compiles only what changed. A newer Horizon is taken
only when the player asks: native-build --update starts from the newest installed release or Nightly (a full
rebuild); native_mods::update_for says which one, if any, the window can offer.
The compiler runs as the release's build does, whatever the player's own Rust setup: the launcher's toolchain
drops the variables that change compiler flags or the target (RUSTFLAGS, CARGO_ENCODED_RUSTFLAGS,
RUSTDOCFLAGS, CARGO_INCREMENTAL, CARGO_PROFILE_*, CARGO_BUILD_* but CARGO_BUILD_JOBS, CARGO_TARGET_*,
rustup's and the wrappers'), and starts Cargo from the disk's root with --manifest-path. Cargo reads .cargo/config.toml from
the folder it starts in and every one above it, whatever CARGO_HOME says, so from the launcher's data folder it
would read the player's ~/.cargo/config.toml, whose [profile], [build] and [target] settings beat the
workspace and the environment; no switch turns that search off. The source's own .cargo/config.toml, if it
has one, is passed with --config.
Every path the build's tools see — the toolchain, the Cargo home, native/ and the tools' temp dir (TMPDIR,
TMP and TEMP, where gcc, the assembler, ld and cargo install put their scratch files) — comes from
backend::build_dir: the launcher's data folder, resolved (never through a link, which horizon-mixins
refuses). On Windows, when that path is not spelled in ASCII — a Cyrillic user name in %APPDATA%, which
MinGW-w64's tools can't open — it is a real folder under ProgramData named for it (horizon-launcher-<id>),
and only when that can't be created the data folder's short (8.3) name. A short name alone is not enough
(#6009): rustc canonicalizes its own path to find its sysroot, which spells the long name again for the
linker. A data folder that is itself a junction to a drive with an ASCII path resolves to that and needs
neither.
Install from GitHub
An author can ship a native mod without a registry pull request per version: the player installs it from a
GitHub link, on the Mods page ("Add from GitHub") or with horizon-launcher mods add-github <url> [--mod ID | --all] [--instance NAME] --trust-native-code. The link is https://github.com/<owner>/<repo>
(its latest release), .../releases/tag/<tag>, .../tree/<ref> or .../commit/<sha>.
- Resolved to something exact. GitHub's public API (no sign-in; a rate limit is said plainly, and
HORIZON_GITHUB_TOKENraises it) turns the link into a release's.hmodfile or a commit SHA, never a branch that moves. A release with a.hmodasset installs that file (its SHA-256 is compared with the one GitHub lists). With no release or no such asset, the launcher fetchescodeload.github.com/<owner>/<repo>/tar.gz/<sha>and looks in it for mods by the rules ofnative-build <folder>: a flat project withhorizon-mod.toml,[native],Cargo.tomlandsrc/. Several mods: they are listed, and the player picks one or installs all. Each source folder pins the Horizon commit in[native] horizon; the launcher packs the checked project into a.hmod. Release packages must contain native code; content-only packages are rejected by this native installer. - Checked like the registry's files. The
.hmodis read ashorizon_mod_package::index::native::Bundle::readreads a registry file (flat source and content, size limits, nothing run) before it is installed. - Said first. Before anything is downloaded: "not reviewed by the Horizon registry; native mods run with full
access to this computer; read the source", with a link to the exact commit. The command needs
--trust-native-code, asnative-builddoes. It is not an MCP tool: trusting native code is the player's decision, never an agent's. - Origin kept.
horizon-native-mods/<id>.origin.jsonbeside the.hmod: owner/repo, release tag (for a release), commit, asset name and the SHA-256 of the installed file.mods listshows "from GitHub: owner/repo @ tag" (or the commit's first 7 digits). - Updates.
horizon-launcher mods update-github [ID] [--check]compares a mod from a release with the repository's latest release, and a mod from a commit with the head of the default branch; a newer one is installed with the same warning and--trust-native-code.mods remove <id>removes it. A mod already installed from the registry (or by hand) is never replaced.
Installing turns Native mods on, as any native mod does; then build as usual.
Cache and disk
The key hashes the commit, the horizon-mixins commit, Rust's version, the profile and features, and every file of
every bundle. The same set on the same commit is never built twice. A change gives a new build, and
native/target/ stays, so a rebuild compiles only horizon-client and what the mods touched.
Incremental rebuilds. With Balanced and Developer, Cargo recompiles only the changed parts of horizon-client, not the whole
crate with LTO. Patched source is cached separately by its mixin inputs and pins (the three most
recent snapshots are kept), so editing only a trait crate skips the mixins pass. This holds because
the stage (native/stage/) is one folder for every build and the launcher writes
only the files whose content changed (Cargo goes by file times), switching mods on the same commit reuses the same
native/target/, and the launcher removes CARGO_INCREMENTAL and CARGO_BUILD_INCREMENTAL from the build's
environment. Optimized has no incremental compilation (the release profile's own).
Build status. Before it starts, the build identifies its plan and profile: up to date, incremental rebuild,
first build, or prebuilt game. While compiling it shows the crate and crates done of the total, from Cargo's own
progress. Afterwards it identifies the completed profile and plan. Neither the window nor native-build promises
or displays a build duration. native/build.log retains Cargo's diagnostic output, without the progress bar.
Native client builds share the prepared resources of their base version instead of copying them into every build. The launcher passes that base folder to the game as --resources and retains the base while an installed native build references it. Remove those builds before removing their base. Dedicated server exports remain standalone and include their own resources.
Disk use varies with the platform, profile and set of mods. Run horizon-launcher storage to inspect the actual
cache rather than estimating its size from another computer's build.
Instance references and newest-build retention select which builds could be disposable; process scans never prove that a game cannot start using them. Current game builds take their own kernel-held runtime leases, including direct starts and Windows launches. Unix launcher starts also run through a holder. These claims survive the launcher exiting or being killed and remain held until the game process exits.
Cleanup classifies each unit separately. An installed version requires its own executable's current lease capability. Shared caches conservatively require that every potentially referencing local version and cached native build has that capability, including incomplete builds that an older executable could still start. A pre-holder build therefore keeps shared caches protected without preventing removal of an unrelated capable version. Kept space appears in Storage with its reason instead of being counted as freed.
Deletion reacquires the unit exclusively and rechecks the capability decision. Executable publication (installation, import, prebuilt extraction and compilation) shares a registry publication lock; cleanup holds it exclusively from its capability recheck through detachment, so an older build cannot land between the scan and the rename. The central storage LRU replaces separate native spare-build quotas. Installation completion markers are removed first. Markerless cache and resource units are renamed away on the same volume before their detached trees are deleted, so a late start sees either the complete unit or no unit, never a partially cleared directory. A late game that finds cleanup's exclusive lease, missing registration, missing resources or an incomplete install refuses startup with a clear message rather than opening half-deleted files. Start the launcher once to register an older installation before starting its executable directly.
Rust/Cargo/temp build-tool caches retain their Cargo-lock cleanup rules. native/target/ and its profiles remain
residual even for current builds: their in-place Cargo cleanup preserves lock inode ancestry, but they still need
a completion marker or a retire-executables-first protocol before automatic or manual eviction can be safe.
Units containing operational Cargo lock files are likewise retained.
Private launch records and raw stdout/stderr capture files are collected as one group only after every protocol owner exits, under an exclusive namespace gate. Malformed records and unknown files remain untouched. Record collection retains its 4,096-entry trigger. The permanent gate and published live-unit keys are not collected; deleting a live key would break direct startup's noncreating contract. Cleanup retires a deleted unit's own key under its exclusive lease. Units deleted outside the launcher can leave empty registry files behind.
Trust
- A notice, then a warning. The first native install shows a trust notice once. The command needs
--trust-native-code; the window shows a warning in red before every build: the mods run with full access to the computer, and their ids and versions. The Mods page says who made each one, and "Not made or endorsed by the Horizon project" for any mod not Horizon's own. - The registry (horizon-rust/horizon-mods) lists them as their own kind,
native-mod, only after a maintainer has read the source and the repository they come from. Its entry names the GitHub repository (repo); each release of it with a.hmodasset is a version once the bundle passes the registry's checks, with no further review (nothing is built or run by the registry; the player's launcher builds it behind its warning). The.hmodcontains the flat Rust project and may containassets/,data/and an optionalicon.png. The launcher checks the bundle without running anything. Native code is trusted. The file is pinned by SHA-256 like every registry file; its identity and exact build pins come fromhorizon-mod.toml.revoked.tomltakes a bad release down. A project's kind never changes. - Isolation of data. Native mods run only in the instance they are installed in, with its worlds, options and logs (the default Horizon instance's, when they are installed there); other instances play their own versions.
- What this does not do: sandbox the compiled game. horizon-mixins' checks stop mistakes, not a malicious helper (security notes).
Multiplayer
- Single player and LAN host: the mods run in the host's game and its integrated server, so a server-side hook applies to every player of the world.
- Joining a server: client-side hooks run locally. Configuration requires matching both-side mod ids and versions; a refusal names the missing, extra or different-version mods (configuration policy).
- Dedicated server: native mods run in a standalone engine export built with
--server-output, not in an instance build (--server-outputconflicts with--instanceand needs an explicit modFOLDER). See Standalone server export.
Standalone server export
horizon-launcher native-build FOLDER --server-output DIR --version TAG --trust-native-code [--profile PROFILE] [--allow-jar-downloads]
Builds a standalone dedicated-server engine into DIR, which must not exist yet. FOLDER (a mod folder or a
folder of them) is required; no instance is read, selected, edited or played, and no launcher config is written.
It reuses the pinned toolchain, source and horizon-mixins and compiles --no-default-features (no audio or
controllers). --version pins the Horizon release/Nightly the
export reuses (explicit for reproducibility). The export holds the built horizon-engine executable, prepared resources
the canonical horizon-native-mods/<id>/ projects and the provenance native-mods.json, and prints its absolute path. It runs on the same OS/architecture as the
host only (no cross compilation), needs a prepared installed launcher base release, and survives cache pruning
because it is copied outside the managed folders. On Windows use the printed horizon-engine.exe path.
For wasm mods that declare internals, put their .hmod packages directly in FOLDER, alongside any
native projects. A folder containing only wasm packages with internals also works. The export compiles
their declared internals and raw listener targets into the engine's table, without consulting an
instance or its enabled-mod list. The selected paths are recorded in native-mods.json and changing
them selects a new cached build. Packages without wasm internals do not select table entries.
Install and enable the same .hmod packages in the server game folder's horizon-mods/ as usual;
the export carries the compiled table, not those packages or the server's configuration.
A mod's crate depends on horizon-mod-api and never on the game: a horizon-client dependency,
under any name or target, is refused naming the mod and the key, before toolchain work. The mod API
has no sound or controller features, so server and client builds stage the same crate.
Declared jars use the same exact-hash preparation as ordinary launches. A server export refuses
missing jars by default and never asks interactively; --allow-jar-downloads permits the shared
preparer to download the exact files into the local cache. Cached jar bytes are never exported.
When moving the runtime to another machine or game home, prepare its jar cache separately; errors
name the required files and cache paths. No Java code from a jar runs.
Point a server at it:
horizon-server --dir SERVER config set engine.path=/absolute/export/horizon-engine
horizon-server --dir SERVER restart
Updates do not rebuild a pinned engine.path: build a new export folder, set engine.path to it, restart, and keep
the old folder for manual rollback. The supervisor does not auto-update or rebuild a pinned engine path
(remaining work in horizon-rust/horizon#6304). To inspect the running server, its authenticated
GET /api/v1/mods queries the engine's canonical LoadedMods inventory:
mods lists loaded native, wasm and content package ids and versions in inventory order.
provenance separately reports the configured program's build record, which may differ and is never
used as an inventory fallback. A stopped or unreachable engine returns HTTP 409, not an empty list.
Configuration mod matching is implemented as described above.
Native mods run unsandboxed: only build code you have read and trust.
--trust-native-code records the operator's decision; an agent must not supply that consent.
Developer acceptance workflow
Use a disposable server folder and fresh homes when developing:
SCRATCH=$(mktemp -d)
export HORIZON_HOME="$SCRATCH/game" HORIZON_LAUNCHER_HOME="$SCRATCH/launcher"
export XDG_DATA_HOME="$SCRATCH/data" XDG_CONFIG_HOME="$SCRATCH/config"
export HORIZON_NO_AUDIO=1 HORIZON_LAUNCHER_NO_SELF_UPDATE=1
export HORIZON_LAUNCHER_TRASH="$SCRATCH/trash"
export HORIZON_LAUNCHER_CLIPBOARD="$SCRATCH/clipboard"
export HORIZON_LAUNCHER_DESKTOP="$SCRATCH/desktop"
Install and prepare the base release in that scratch launcher home first, then test your own licensed bundle:
horizon-launcher native-build bundle/ --server-output $ENGINE --version TAG --trust-native-code(scratch homes,HORIZON_NO_AUDIO=1,HORIZON_LAUNCHER_NO_SELF_UPDATE=1).horizon-server --dir SERVER config set engine.path=$ENGINE/horizon-engine;horizon-server --dir SERVER run.- Join with the same mod's client half where the hook needs one; test lifecycle, tick, command and net hooks.
- Headless compatibility: the mod's server code must not require an audio device; run the server audio-free (
HORIZON_NO_AUDIO=1).
Level sounds
Implement play_level_sound_at_position or play_level_sound_at_entity to edit
or cancel a level sound before it is broadcast (server) or played (client).
Match the hook's HostSide to distinguish the requests; the event's sound,
source, volume and pitch describe that side's request, and original volume and
pitch remain available after edits. Returning Flow::Cancel suppresses that
request. A server cancellation sends no packet, so there is no client request.
Incoming sound packets, including /playsound, ask the client level hooks.
An entity-sound packet stays bound to its emitter. UI clicks, engine-local
sounds and ClientHost::play_local_sound do not ask these level hooks; the
sound engine's play_sound hook remains a separate, later interception point.
Client-predicted level sounds and LocalPlayer.playSound use those same hooks.
Canceling their sound does not cancel the player action or undo a predicted
block change. A local player's ordinary sound is an AtEntity request but
still plays at the requested position; only a genuinely entity-bound sound
(such as a goat horn) follows its emitter after playback starts.
| Client route | Level event | Vanilla call being represented |
|---|---|---|
| Placement confirmation, predicted doors/buttons/levers | AtPosition | BlockItem.place, block useWithoutItem → Level.playSound |
| Waxing, tool transforms, bucket use | AtPosition | HoneycombItem.useOn, tool useOn, BucketItem → Level.playSound |
| Game-event arrow hit (Players), pufferfish sting (Neutral), elder-guardian curse (Hostile) | AtPosition | ClientPacketListener.handleGameEvent → Level.playSound |
| Own steps, swimming, splash, amethyst chime, honey slide, fall | AtEntity | Entity/LivingEntity → virtual LocalPlayer.playSound |
| Own hurt/death, bundle menu clicks, consume effects | AtEntity | LivingEntity, BundleItem, Consumable → LocalPlayer.playSound |
| Player-bound sounds, including the local goat horn | AtEntity | Level.playSound(except, entity, ...) |
| Block-breaking/level-event effects, explosions, pickup packets | None | Client-level playLocalSound or direct sound manager playback |
| Dragon/fang/sniffer local effects, ticking ambience, UI | None | Entity-local effects, sound instances or widgets, not playSeededSound |
Server-only gameplay sounds keep their existing broadcast-time event. They are
not moved earlier merely because their implementation also calls Audio.
Engine playback and live parameters
play_sound also observes transient playback updates. event.starting is true for a
new sound and false while it plays. event.instance identifies the same engine-local
instance across those calls. At an update only volume and pitch edits apply;
None stops playback. Invalid edited volume (negative or non-finite) or pitch
(non-positive or non-finite) refuses that refresh. Updates do not replay the sound,
resolve a different source, or modify the instance's stored settings. This is the
same interception point, not a parallel sound_play hook.
Client particles
ClientHost::add_particle submits the API's ParticleOptions, a world position and
velocity to the ordinary client particle queue. The client must have a loaded player
and world. Non-finite numeric inputs are refused. A successful submission does not
promise a visible particle: provider availability, sprite sets, distance and the
player's particle settings still apply. This method is not available on ServerHost.
Particle options carry a ParticleKey, not an engine particle enum or a provider name.
Request the key with Registrar::particle_key during registration and retain it. The game resolves
provider identities once when content loads. ParticleOptions::simple(key) handles both vanilla
and custom sprite providers; custom providers still require an assets/<namespace>/particles/<path>.json
sprite set. All option constructors take the resolved key, including dust, item, trail,
vibration, shriek, sculk_charge and dust_transition. Use the corresponding vanilla key
for these parameter families. Sending an option performs no string parsing or registry scan.
For stack-derived breaking or eating particles, call
ClientHost::item_particle_options(&stack) and pass the returned options to
add_particle. This read resolves the ground model with seed 0, preserving all
layer particle sprites and resource-pack and stack-component overrides. It returns
None for invalid stacks or unavailable resources/provider. It is available in
Setup, Reloading and World, on native and wasm. Resolve again after resource reloads.
It allocates a sprite list only when called; it adds no per-tick work.
Bot movement and block collisions
PlayerRef::simulate_move predicts one server player's movement tick from
PlayerInput, using the game's movement implementation without replacing the
live player's body. It returns SimulatedMove with position, velocity, ground,
horizontal-collision and sprinting results. It refuses stale or inaccessible players,
dead players, spectators, riders and invalid numeric input. A supplied velocity is
input to the prediction, not a write to the player.
LevelRef::collision_boxes reads block collision boxes within a finite, ordered
axis-aligned box. It excludes entity and border collisions. A refused level or a
query covering more than 1,000,000 cells returns None; a valid query with no
collisions returns Some containing an empty vector. Keep queries bounded rather
than scanning a whole level each tick.
LevelRef::block_shape(host, state, pos, kind) reads an explicit BlockState's
outline or collision boxes with an empty context at a position in that level.
The supplied state can differ from the world's block, without changing it;
level-dependent shape inputs still come from the actual level. To query the
stored block, first read level.block_state(host, pos) and pass that state.
This server read returns block-local boxes, or None for an unknown state,
stale handle, closed level or client. Parked levels are supported.
These queries take typed handles for their natural receiver. There are no parallel UUID-only or dimension-string helpers.
Extending block entity validity
Use Hooks::block_entity_type_add_blocks after retaining block and type keys from
register. event.modify(kind, &[block]) extends one existing type;
event.modify_matching(predicate, &[block]) extends all matching registered types.
The event uses a RegistrationContext and runs before worlds load. Duplicate additions
are harmless. Unknown keys and blocks that do not share the target's common vanilla
superclass fail startup.
A block may be valid for several entity types. Its native declaration or original
vanilla template still selects creation and ticking. For example, a block declared
with template: "minecraft:vault" and added to minecraft:vault creates and saves the
vanilla vault entity. Adding another valid type does not replace that factory. Sign
templates retain their existing membership defaults. Validity is frozen with the
canonical type table and participates in the multiplayer content identity.
Typed bulk block edits
BlockEdit.palette contains complete BlockState values. Construct them with
BlockState::of(&host.block_states(), block, constraints) (or change an existing state
with with). filters contains owned StatePredicate values. StatePredicate::block
validates property names and values strictly; StatePredicate::tag retains arbitrary
property requirements, including for an empty tag, and rejects duplicate properties.
A predicate requires block identity or tag membership AND every supplied property. Omitted properties are wildcards, never default-state requirements. An unknown or missing property on a tag member never matches. Sparse, non-Cartesian state tables are supported: a partial predicate does not need to form a complete state with defaults.
All palette entries, predicates and run indices are prepared before any mutation. NBT is not representable. At most 65,536 filters are allowed. Unloaded chunks and out-of-height portions of runs are skipped. Edits preserve strict block-entity behavior, notify no neighbors and send whole changed chunks.
ColumnBlocks.palette also contains complete BlockState values. States of the
same block with different properties have distinct entries. Only distinct numeric
states are converted to API values, not each block in the returned columns.
Refusing dimension travel
entity_travel_to_dimension runs before a server entity moves to the target
level. Returning Flow::Cancel refuses that entity's travel. Passengers are
asked recursively: refusing one leaves it dismounted in the source level,
with its own passengers, without refusing the vehicle's transition. Discarding
an entity through its handle during the callback also prevents its travel.
Callbacks may change riding relationships. An original passenger edge is restored
only if the checked mount operation still permits it; otherwise the passenger
stays behind, without overwriting any new vehicle chosen by a callback.
A falling block ticks outside the level's entity list, so its handle is not
accessible during this callback.
A local unsigned developer base
A fresh launcher home can use a game built from a trusted checkout without installing a signed release first. This is a developer route, not a release channel: the launcher labels the base and its derived games Local unsigned developer build, including the source commit. Automatic updates never replace it. The game sends neither telemetry nor the live-count ping when launched on this base.
Commit tracked changes before building. From that clean checkout, build the game and its companion programs, then compile the original Horizon exporter classes against your own pinned Minecraft installation:
cargo build -p horizon-client --bin horizon-client -p horizon-client-lib --bin horizon-ctl \
-p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
bundle=$(target/debug/horizon-prepare compile-exporters --root "$PWD" | tail -n 1)
mkdir -p exporters
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
horizon-launcher developer-install "$PWD" --program "$PWD/target/debug/horizon-client" --trust-unsigned
--trust-unsigned permits executing the checkout's build code, not just copying the game. The command
runs the existing cargo run -p xtask -- package release packager. Trust both the checkout and the executable
with your computer's full rights. Missing companion programs, missing or incompatible exporters, dirty tracked
inputs and executables for another platform are refused. Nothing publishes a release or changes the selected base.
The returned developer-… tag hashes the packaged files and a complete tracked HEAD source snapshot. Select
that exact tag, prepare its resources from your own Minecraft installation, and pin an instance to it:
horizon-launcher settings selected-version DEVELOPER_TAG
horizon-launcher prepare --version DEVELOPER_TAG --minecraft YOUR_MINECRAFT_FOLDER
horizon-launcher instance create Development
horizon-launcher instance version Development DEVELOPER_TAG
horizon-launcher native-build YOUR_NATIVE_MOD_FOLDER --instance Development --trust-native-code --profile developer
Native builds use the saved local source snapshot, including commits never pushed to GitHub. They do not fetch
Horizon source from GitHub or silently fall back when that local snapshot is missing. A changed checkout needs
a new commit, rebuilt executable and another developer installation. import-build --version DEVELOPER_TAG
can also reuse this base; an unselected developer base is never chosen as an automatic fallback instead of a
release or Nightly. Remove it with the normal horizon-launcher remove DEVELOPER_TAG command when no longer needed.
Reading data maps
A data map attaches a value to the entries of a registry (NeoForge's DataMapType), here
minecraft:item and minecraft:block. Data packs and mods' files set the values; a mod reads
what the server made of them, so a server pack can rebalance a mod's numbers without a rebuild.
Describe the map once with DataMap: its id, the registry its entries are in (Entry, an
ItemKey or a BlockKey), and how a value decodes. Values arrive as the NBT form of the JSON a
file held: a whole number is an Int, or a Long past i32, a fraction a Double, a boolean a
Byte, an object a Compound and an array a List. Integer tokens must fit signed
64-bit NBT Long; larger unsigned integers return DataMapError::Undecodable,
never a rounded Double. Fractional/exponent tokens use the JSON parser's f64
value (including an integral value written as 1.0). A JSON null anywhere in a
value also refuses the entire read as Undecodable; it is not an empty string.
Empty and heterogeneous arrays preserve their elements as in-memory List values.
These rules are identical for native and wasm readers.
use mod_api::{DataMap, DataMapTypesEvent, ItemKey, ServerHost, Tag};
/// `mymod:emc`: an item's value.
struct Emc;
impl DataMap for Emc {
type Entry = ItemKey;
type Value = i64;
fn id(&self) -> &str {
"mymod:emc"
}
fn decode(&self, value: &Tag) -> Option<i64> {
match value {
Tag::Int(value) => Some(i64::from(*value)),
Tag::Long(value) => Some(*value),
_ => None,
}
}
}
// In `impl Hooks`: register it each time the server loads its data.
fn data_map_types(&self, _: &mut dyn ServerHost, event: &mut DataMapTypesEvent<'_>) {
event.types.push(Emc.ty());
}
Its values are data/mymod/data_maps/item/emc.json in any data pack, and files the mod pushes
through data_pack_files under data_maps/mymod:item/emc:
{ "values": { "minecraft:coal": 10, "#minecraft:logs": 32 } }
Read an entry with item.data(host, &Emc) or block.data(host, &map), from any hook with a host:
Ok(Some(value)): the merged value. The newest file wins, a#taggives every entry in it the value,replacedrops what earlier files set andremovetakes entries away.Ok(None): the map gives this entry no value.Err(DataMapError::UnknownType): no mod registered the map for that registry in this load.Err(DataMapError::Undecodable): the value is not onedecodetakes.Err(DataMapError::Unavailable): no maps are loaded here. Data maps live on the server (the single player server thread included) and are not synced to clients, so a client always answers this, as does a server before its world's data first loads.
The server merges every map when its data loads: a world's first open and every /reload. A
read sees the last load whole, never part of one, and a value a reload drops is gone at once.
data_maps_updated fires after each load, the place to rebuild anything computed from the
values; reads also work in the world. A lookup reads the loaded map: it parses nothing and
touches no file.
Wasm mods read data maps the same way, with the same answers.
Machine-readable SDK catalog
Tools discover a build's hooks and injection points through mod-catalog.json,
shipped at the root of each prebuilt SDK and beside points.json in a developer
source build's catalog directory. Converters, linters and IDE helpers must read
this file rather than source paths. Internal source layout is not a tool API.
Fork Nightlies also attach mod-catalog.json as a standalone release asset,
listed in the signed SHA256SUMS. Download the current
catalog asset
from the fork Nightly release,
or use the corresponding dated release for an older pinned build.
It is generated from the Nightly's exact published commit using the same
generator as the SDK. Verify it with that
release's checksums and build-specific signature; do not mix the latest catalog
with an older pinned build. The catalog follows the release when it rotates to
a dated Nightly tag and has the same retention as the other Nightly assets.
The JSON document has schema_version: 1, api_version, hooks and points.
Consumers must refuse unknown schema versions. The API version is the mod
manifest API version; it is independent of the catalog format version.
Hooks are sorted by id and describe side, nullable scope (Part kind and
key), contract, ordered arguments with API name and type, and nullable
output. Types are Rust API event field types, including their borrow lifetimes.
Points are sorted by name and target; a target distinguishes equal names.
Each includes its supported access values (api, internals), API record
and ordered args, return contract and type, raw shape when available, and
nullable api_refusal. Refused and native-only points remain in the file.
The build generator owns this catalog. Output has no timestamps or absolute paths. A format change requires a schema version change; tools should use the catalog accompanying the build they target. CI regenerates the catalog in-test and compares the two documents for determinism. The full file ships in the SDK, developer source build and fork Nightly release; no committed fingerprint needs refreshing when independently authored hooks land.
Server item particles
Set ParticleOptions::item_stack(item_particle_key, stack) and call
level.send_particles(host, &options, state, at, spread, speed, count) with a
LevelRef and a ServerHost. The key is the
load-resolved minecraft:item particle key; the stack is an API ItemStack.
The same request crosses the native and wasm boundaries through Wire.
The server validates the stack against the registered items and component and
count limits. Invalid stacks return false without queueing a particle. A true
result means queued, not necessarily drawn. The ordinary particle packet carries
the item stack, not server-selected sprite names. Each client resolves its ground
item model with seed 0, through the same path as
ClientHost::item_particle_options, so its resource pack and stack model
components determine the layer particle sprites.
The resolved ParticleOptions::item sprite list is for local rendering; it is
not a substitute for the stack on server sends. Block, block-marker, falling-dust,
dust-pillar and block-crumble requests still require a valid block state argument.
Trail and vibration requests are still refused by the server send.
Registering cauldron interactions
Use Hooks::register_cauldron_interaction with a RegistrationContext and
RegisterCauldronInteractionEvent. Retain typed BlockKey and ItemKey values
from registration, as described in the registry-key chapter. The hook runs on both
sides when preparing the resource snapshot; publication happens only after the
complete candidate validates. Failed preparation retains the previous snapshot.
The names correspond to NeoForge 26.1's documented
RegisterCauldronInteractionEvent, Dispatcher, and Interaction. They remain
unverified for NeoForge 26.3. See the public cauldron documentation.
Register an exact item on a block's dispatcher:
fn register_cauldron_interaction(
&self,
_: &RegistrationContext,
event: &mut RegisterCauldronInteractionEvent<'_>,
) {
let handler: Arc<dyn Interaction> = Arc::new(Syrup { empty_bottle: self.glass_bottle });
event.interactions.register(self.empty_cauldron, self.syrup_bottle, handler.clone()).unwrap();
event.interactions.register(self.syrup_cauldron, self.syrup_bottle, handler).unwrap();
}
A custom block opts into dispatch by registration; its name need not end in
cauldron. Register the filled target too when a plan replaces the empty block
with a custom cauldron. Its declared state schema must admit the proposed state,
for example level=1 in a 1..3 level property. register_to_all(item, handler)
queues registration over the complete prepared dispatcher set, including custom blocks
registered later in the hook sequence. Conflicts surface when preparation finishes;
they fail the whole preparation and name both owners. register_to_all does not
return Duplicate directly. Immediate admission errors from either registration
method are also stored, so ignoring an error cannot publish a partial snapshot.
Vanilla entries are prebound in the same table. With register, replacing one,
or registering a second entry for the same dispatcher and item, returns
CauldronRegistrationError::Duplicate { owners }, naming the existing owner and
the attempted owner, and refuses publication. There is no override flag. Unknown
block or item keys refuse registration. On water cauldrons, vanilla's dye tag
always precedes exact-item entries: even washing that returns Pass does not fall
through to the mod's entry.
Implement Interaction::decide(&dyn Host, &CauldronUse) for eligibility. It reads
the complete captured held stack, target state, actor, hand, dimension, creative
flag and underwater flag. Return Pass to leave normal item use available,
Consume to stop it without a plan, or Success(CauldronPlan) to propose a block
replacement, one held-item exchange and deferred effects. The exchange uses the
existing survival, creative, inventory insertion and overflow-drop rules.
For example, a handler can propose a syrup-bottle exchange without changing the world during the decision:
struct Syrup { empty_bottle: ItemKey }
impl Interaction for Syrup {
fn decide(&self, host: &dyn Host, _: &CauldronUse) -> CauldronDecision {
let Some(filled) = host.block_state_id("example:syrup_cauldron[level=1]") else {
return CauldronDecision::Pass;
};
CauldronDecision::Success(CauldronPlan {
block: Some(filled),
exchange: Some(ItemStack::new(self.empty_bottle, 1).unwrap()),
sound: Some(Identifier::parse("minecraft:item.bottle.empty").unwrap()),
fluid_event: Some(Identifier::parse("minecraft:fluid_place").unwrap()),
statistics: vec![
Identifier::parse("minecraft:use_cauldron").unwrap(),
Identifier::parse("minecraft:used").unwrap(),
],
})
}
fn fire(&self, _: &mut dyn ServerHost, _: &CauldronUse, _: &CauldronPlan) -> CauldronFire {
// No extra writes: the engine applies the validated plan.
CauldronFire::Applied
}
}
A transfer can carry two distinct statistics: for example
minecraft:use_cauldron and minecraft:used. The latter awards the used-item
statistic for the original held item's key. Each is awarded once; duplicate
statistics and plans with more than two statistics refuse the action. A plan may
also carry one sound and one fluid event (minecraft:fluid_place or
minecraft:fluid_pickup). State and returned stacks are validated against the
current resources before mutation.
Interaction::fire(&mut dyn ServerHost, &CauldronUse, &CauldronPlan) runs only on
the server, inside the shared action transaction. Return CauldronFire::Applied
to permit the plan and supported callback writes to commit. Refused, a panic,
a guest trap, stale preconditions or an enclosing abort discards the action.
Sounds, statistics, events and overflow drops wait for root commit. Uses the
shared transaction and runtime limits; no cauldron-specific budget.
Client prediction selects the same entry, but skips custom handlers entirely:
it does not call decide or predict writes and consumes the local action while
waiting for the server result. Vanilla handlers continue to predict normally.
The server owns inventories, statistics, drops and game events. Matching custom
binary builds and synchronized resource generations are not established by the
current handshake, so custom prediction is suppressed. Native and wasm handlers
use the same generated registration and callback route;
CauldronFire defaults to Refused when a generated callback cannot run.
Dropped items during block-entity ticks
The overlay below is the port's own transactional requirement, not vanilla or NeoForge behavior. NeoForge adds no event, deferral or drop capture around a block entity's tick; new item entities join immediately. Block-break and entity death/shear captures are separate mechanisms.
During a mod's BlockEntityPart::block_entity_tick, existing dropped-item stack writes and removals are proposals. Use ItemEntityRef::set_stack to replace a complete nonempty stack and EntityRef::discard or EntityRef::remove to remove it. Item-place writes join the same scope. Native and wasm mods use the same calls; there is no separate batch method or transaction seal to finish the tick.
Later Parts see earlier proposed stacks in registration order. A proposed removal hides the item from handle resolution, item narrowing, entity selectors and item-place reads. Explicit nested host transactions can abort their own proposals; committing one still waits for final tick acceptance.
The engine validates the final block-entity snapshot and every item's original stack and residency incarnation before publishing either side. Rejected revisions, invalid stack conversions, malformed guest responses, stale item preconditions, and removal or replacement of the ticking block entity discard the proposals. A successful host setter means the proposal was accepted into the scope, not that final writeback has already committed. A numeric entity ID never retargets a replacement or another level. Removal notifications run after publication and after detaching the completed overlay.
This is deliberately not a whole-world transaction. Pickup delay and target changes, spawning, block changes, motion and teleportation, non-item removals, players, attachments, neighboring block entities, weather and world effects remain deliberately immediate. They are not undone when the snapshot is rejected. An immediate operation that destroys a staged item's precondition can reject the pending proposal while retaining its own effects.
With no scope open, the engine behaves exactly as vanilla for these item operations: changes are immediate, the overlay remains None, and nothing is staged. The prepared-resource adapter regression native_tick_publishes_or_rejects_item_proposals_with_writeback checks that its outside-scope setter changes the item immediately and leaves both overlay and tick key None. Vanilla block-entity tick item changes are immediate too; only the port's mod block-entity host operations gain the stronger atomicity above.
Level block notifications and cracks
level.send_block_updated(host, pos) tells clients to read the current block again.
It does not change that block or mark the chunk unsaved. Repeated notifications of
one position are coalesced until the update log is consumed. Inside a transaction,
notification waits for the outermost commit; aborting sends nothing. A stale,
parked, unloaded or out-of-height write returns false and queues nothing.
level.set_break_progress(host, breaker, pos, stage) broadcasts a server crack.
Use a stable negative breaker ID for a machine. Stages 0..=9 draw a crack;
any other stage clears it. This is distinct from
level.set_client_break_progress(client_host, breaker, pos, stage), which draws
only in the local client's view and requires &mut dyn ClientHost, not a server
host. Local cracks expire after 400 ticks without an update and clear when the
client changes levels or disconnects.
level.block_break_speed(host, pos, tool) reads the tool's destroy speed on that
block, including its minecraft:tool component rules and default speed. It does
not include Efficiency, player attributes, effects, fluid/airborne penalties or
block hardness. Bare hands return 1.0; an unavailable position returns None.
This read takes an immutable host and does not modify blocks or tools.
Measuring styled text outside drawing
A tooltip component can determine its dimensions before it receives any draw
context. Use ClientHost::text_width(&Text) and text_split(&Text, width):
these are reads of the client's active font in unscaled GUI pixels, also usable
in client setup. Native and wasm mods use the same methods.
Construct an owned Text with its content, style and children. A bridge with a
flat component representation can build a TextForm and call
Text::try_from(form) once at its boundary; the host reads accept that validated
Text, not JSON or engine text types. Both methods return a TextFormError if
the supplied component cannot be represented at the engine boundary.
Width is the same styled and translated width used by the engine's tooltip
layout. Wrapping follows Font.split(FormattedText, width): it keeps styles,
breaks at newlines and fitting spaces, and uses the vanilla one-pixel minimum
for widths below one. Returned lines are API-owned Text values. Translation
uses the current client language; a later language or resource-pack font change
is reflected by the next call, so do not retain dimensions across reloads.
NeoForge's ClientTooltipComponent receives a Font in its width and drawing
methods. These reads use that same role—the active client font—without passing
an engine font object or requiring a DrawContext. They do not draw anything
and do not expose a full rendering context.
For vanilla Component.getString() behavior, use
ClientHost::text_string(&Text) -> Result<String, TextFormError>. This client
setup read also works outside drawing on native and wasm. It uses the same
component conversion as measurement: translation arguments, key names and nested
siblings resolve against the current language on every call. The result contains
plain characters, not serialized JSON or translation keys. Styles do not add
characters; literal legacy § formatting codes remain in the string, unlike the
font's formatted rendering. Invalid components return the same boundary errors
as the other text reads. Do not cache resolved strings across language changes.
For vanilla Component.getVisualOrderText() behavior, use
ClientHost::text_visual_order(&Text) -> Result<Vec<VisualTextCharacter>, TextFormError>.
Each API-owned Wire record contains a Unicode scalar (scalar), its full
resolved TextStyle, and the sink's source_index. Characters are already in
visual order, including Arabic shaping and mirrored punctuation; do not apply
bidi a second time. This read uses the engine's existing language and bidi
pipeline, is available outside drawing including client setup, and is identical
on native and wasm. It has no documented NeoForge hook counterpart.
Sink indices are UTF-16 offsets local to a constant-style substring within a
directional run, not global component positions. For example, A😀B yields
indices 0, 1, 3; Hebrew שלום yields 3, 2, 1, 0. A style or bidi run boundary
restarts the index. Shaping can shorten the string, so these are not guaranteed
to identify original input scalars. Use the records to re-layout existing tooltip
lines without losing click, hover, font or other style information. Like the
other reads, invalid component input returns its boundary error.
Action keybind IDs such as key.jump resolve through the client's current key
binding to their assigned physical key display name, matching vanilla's KeybindContents.
Physical key names resolve normally.
Injections
A function-slot injection is a [[inject]] table in horizon-mod.toml. It is an escape hatch for a moment
the mod API has no hook for, and every injection is a request for a hook: write it so it can become one
by renaming its target, then ask for the hook.
Choose access = "api" for API-owned records, or access = "internals" for a declared
internals facade. API injections never rebuild the game: a missing prebuilt slot is refused, and source rebuilds and call-site injections require internals access or a hook request. Both use the same [[inject]] fields. Native internals listeners use a
compiled slot when available; an unlisted function or supported call requires a source rebuild.
The launcher generates the private horizon-mixins input; mods name a Rust with function,
not a source payload file.
An API point injection names a crate-root with function and listens at an admitted head or return
slot (shape rules). The prebuilt game fills those slots at load with the
mods' listeners, without changing game source.
There are two access tiers, and they never mix:
access = "api": native andmod.wasmalike. A point is a hook whose signature comes from the engine function. The listener gets the point's record (and the side's host at aGamemethod's point, below): a copy of every parameter as an API value (scalars, text, std containers,mod_apitypes, and the engine types with amod_apimirror, such asTextandBlockStateId), pluscancelorresult. A&mutparameter is copied in and its place gets the listener's value back; by-value and&Tparameters run the function with the listener's values. Both targets get the same record and the same semantics. A point with a parameter that has no API type yet has no API record: anapilistener there is refused at registration on both targets, naming the parameter and its type.access = "internals": native only. The listener gets the real receiver and a&mutto every parameter in its engine type, and the call kinds and support modules below. It is the mixin tier; amod.wasmdeclaring internals or an internals injection is refused at load: "internals access is native-only".
Writing a declaration
An [[inject]] needs id, at, kind and with (plus call, nth and ctx for a call kind,
copied from the pinned points.json). hash is optional pinning. Without it, the build takes the
function's fingerprint from the catalog of the game [native] horizon pins, which fixes every
signature exactly, and records it in the build's native-mods.json. With it, the build checks it
as written and refuses a stale one with E_INJECT_CHANGED. The same holds for each internals
entry's hash (E_INTERNAL_CHANGED). Moving the pin with cargo horizon-mod update compares the
old and new games for every declaration without a hash: an inject whose function changed refuses the
update with E_INJECT_CHANGED until you pass --accept <inject id>, as a stale hash refuses the
build, and internals without a hash are reported and stay without one.
Run cargo horizon-mod inject add <at> --kind <kind> --with <listener> in a native mod's folder.
The tool appends [[inject]], preserving comments, formatting and permissions. A symlinked
manifest is preserved; edits atomically replace its referent. Checked [native].horizon metadata supplies hash, pinning it.
--with supplies both listener and unique inject id (default on_inject); implement it separately.
Access defaults to api; edit it to internals when needed. The tool prints each tier's availability
at the place, such as api: refused, argument 2 batch (&mut HudBatch) has no API type yet and
internals: native only. The launcher validates route support.
Call kinds copy canonical call, nth and ctx from the pinned points.json. Use
--call <canonical callee> --nth N (default 0, per callee). Omitting --call requires one callee
at that ordinal. Ambiguous identities and unlisted source calls are refused; no discovery runs.
Unknown places return E_INJECT_MISSING naming the target. Invalid kinds, callbacks,
duplicate ids, or call flags on function kinds leave the manifest untouched.
Refreshing pinned declarations
cargo horizon-mod inject update checks every declaration against the current game's catalogs (a
declaration without a hash has none to refresh);
it does not change [native].horizon (use top-level update to change that pin).
A unique call context that moved reports E_INJECT_MOVED and updates only nth.
Missing targets, missing contexts and ambiguous contexts are never retargeted.
Signature changes report E_INJECT_CHANGED with old and new hashes and signatures,
including parameters, return type and flags. Older cached catalogs supply the old signature;
when unavailable, the report explicitly says so. No hash changes without explicit consent:
run cargo horizon-mod inject update --accept <id> to accept that declaration's new signature.
Repeat --accept for multiple ids. Acceptance cannot override missing or lost targets.
Any refused entry prevents all writes. Successful edits preserve unrelated fields and comments,
use atomic replacement, and an exact no-op leaves the file and its modification time untouched.
Publishing slots to a game checkout
After adding native declarations, request their missing slots in a game source checkout:
cargo horizon-mod inject sync --repo /path/to/horizon
This writes crates/horizon-mod-api-gen/slots/<mod_id>.txt in that checkout, not in the mod.
It emits function paths, at | call | nth | ctx call rows, and type <path> extension rows.
Places already listed by another mod need no duplicate row. Existing rows in your own list
are retained; row-only files and tool-written files are regenerated in sorted order.
A standalone or trailing comment other than the tool's own header prevents rewriting:
the error names its file and line and asks you to move the comment out first.
Running the command again makes no change.
The shared slot parser validates every row before writing. Conflicting call contexts are
refused with both file locations, leaving your file untouched. Invalid mod ids are refused,
including Windows device names (con, nul, com1–com9, lpt1–lpt9, etc.),
and horizon.txt is reserved for the engine seed. Commit the generated list with the game
change that supports it; this command does not compile slots or change the mod's build pin.
Function-slot declarations
API listeners use access = "api" (the default) and the host/API record shown below. Native internals
listeners choose access = "internals" and the generated facade's receiver/raw record:
[[inject]]
id = "before_tick"
at = "horizon_client::game::Game<'_>::tick"
kind = "head"
with = "before_tick"
access = "internals"
hash = "<the function hash in internals.json>"
The function takes the slot's exact receiver and raw record types from the generated internals
facade. head and return use function slots. If no compiled slot exists, native internals
listeners use a private source point and rebuild the game; the callback and its facade record path
stay unchanged. The log says source (private slot, reason) and private rows are never published
in points.json. Targets below mod_points are refused because dispatch would create a Cargo cycle.
before_call, after_call, modify_value, wrap_call and wrap_if also use private source calls
when the canonical callee is absent from every slot list. The launcher validates their exact identity
against the staged SDK before any game build; missing, moved or ambiguous calls refuse. Listed stale
calls never fall back to source. wrap_call controls whether and how often to invoke the original;
wrap_if runs it only when its condition returns true and requires unit output. The unit check runs
once the private site's metadata exists, before game compilation. For all call kinds, use the catalog's
canonical call, nth and statement ctx, not its place fingerprint hash.
The generated Listener, Receiver, Args and Output facade names derive from that canonical callee
and are identical on listed and source routes; physical IDs and dispatch remain distinct. The same
with function therefore compiles on either route without an alias or compatibility export.
The build log prints mod:inject: target -> slot (head/return/site, internals) for each accepted declaration. A changed
hash or missing target is refused, not silently redirected. Points order by descending priority,
then registration (dependency) order; ties within a mod use inject id. Sites retain priority, mod id
and inject id order. To change a function's own arguments, use head: its listener gets every argument
to change. To change a call's arguments, use before_call: its listener gets them mutably, evaluated,
before the original runs. The old spellings modify_arg and modify_call_arg are refused, naming these.
An API replace owns the function, as a mixin overwrite does: its function takes the head's inputs
and returns the point's return type, which the caller gets, and the body never runs. At a point
returning () it returns () and the cancel is forced. It runs after the point's other API head
listeners, whatever its priority, so they may change its inputs but not its outcome; internals head
listeners run after it and can't undo it either. A point whose return has no API form refuses it at
load, naming the point and type, and so does one returning Result<(), E>, whose cancel would
answer Ok(()); a wasm module built without cargo horizon-mod is held to the same check when it
loads. A second replace at one point is refused at registration, naming both mods. A replacing
mod that stops or is disabled hands the function back: it runs as written again.
Native and wasm share it through mod_points::replace!.
An internals replace (native only; wasm refuses it at load with the internals refusal) has the same
contract through the point's raw form. Its function takes the real receiver and the raw record,
&mut to every argument in its engine type, and returns the function's own return type, () at a
unit point, where the cancel is forced:
pub fn own_tick(game: &mut internals::game::Game<'_>, raw: &mut internals::raw::__TickRaw<'_>) {
// the body of `Game::tick` never runs; this does instead
}
A free or associated function has no receiver: the function takes the raw record alone. It runs after
every API and internals head listener at the point, whatever its priority: they may change its
arguments, and an internals head that sets result or cancel before it is overruled by it. One
replace owns a point whatever the tier: an API and an internals replace, or two internals ones, at the
same point refuse the second at registration, naming both mods. A replacing mod that stops or is
disabled hands the function back. The point must be listed with a raw form whose record carries the
outcome: a borrowed return (no raw result) and a Result<(), E> (whose cancel answers Ok(()))
refuse it at load, naming the point and type, and an unlisted function refuses it naming the slot row
to add. The launcher emits it as mod_points::replace!(internals …).
API call sites are refused. Locals and fields use the native source routes described below.
The native table accepts only build pins and supporting modules; unknown fields are rejected.
API head/return functions also load from wasm, with the same record.
For agents
Apply these rules in order and stop at the first that fits.
- A hook or a Part method exists for the moment: implement it. No injection.
- No hook: an API point declaration. Use
[[inject]]withat, lowercasekind = "head"or"return"and a crate-rootwithfunction (hashis optional pinning). Addhorizon-mod-pointsbesidehorizon-mod-api; the function takes the side's host and the point's public record. Keep its body API-only so the same call can become a hook. - You need game internals: declare them and use
access = "internals"with the generated receiver/record types. Native source routing rebuilds the game when no compiled slot exists. File a hook request; do not splice a hand-written helper into game source.
| Field | Rule |
|---|---|
id | unique, nonempty |
at | canonical function or target struct path |
hash | optional at a game function or struct: the tool-written signature hash, pinning it; absent, the pinned game's. Required at another mod's point |
kind, with | lowercase kind and a crate-root callback function; field uses with as its member name |
access | api (default) or internals |
type | required Rust type for field and modify_local; forbidden for other kinds |
priority | descending; defaults to 0 |
call, nth, ctx | exact canonical callee, occurrence and statement context for a call kind |
Function-slot errors name the declaration or target. The generated constructor checks the callback's signature. Freshness errors never redirect an injection to a different function or call.
Naming: events and methods follow NeoForge 26.3 names with no on_ prefix; the event is &mut, handles come from
the event, and writes take host.
The decision ladder
- A hook or Part method exists: use it. Read Hooks and the rustdoc. A per-block, per-item or per-entity moment is a Part method. Nothing in this chapter applies.
- No hook: an API
[[inject]]function. The default. Your crate-root function gets the side's host and the point's record (mod_points::<Point>Args): every argument as an API value, and acancelorresultwhere the function returns()or an API value. It uses onlymod_apitypes. EveryGamemethod is a point on both routes: the prebuilt game was built with them all, and a source build gets the same points, so a point injection builds against the prebuilt game. - You truly need private internals: declare their paths and fingerprints, then choose
access = "internals". The callback depends on that game build, not a stable API contract; it remains a hook request. A missing compiled slot requires a native source rebuild.
The forms
| Form | Where | Game rebuild | mod.wasm |
|---|---|---|---|
API [[inject]] | admitted head/return slots | no | yes |
Internals [[inject]] | head/return slots and supported call sites | only without a compiled slot | no (refused at load) |
| Hook or Part method (no injection) | its documented firing point | no | yes |
Ordering
Head slots run before head source callbacks; return source callbacks run before return slots.
Slots run API listeners before internals listeners. Within each phase priority descends, then
stable dependency/registration order; inject id breaks ties within one mod. A call wrapper may
invoke the original zero, one or several times. A head cancellation skips the body and the return
point, not the listeners after it: every head listener runs and sees cancel as the ones before it
left it, and one may clear it again. Return listeners observe the result only after the body actually
ran. Trait-mod hooks run by #[listener]
priority descending, then dependency order; a cancel skips later mods except those receiving cancelled
events, which may undo it. Hook contracts determine their own cancellation and replacement rules.
Make your injection convertible
An injection becomes a hook when its body is a single call a hook can take. Keep to four rules:
- Take the host, never
&mut Game. - Never read
Gamefields. Whatever you need comes from handles, attachments and services reached from the host. - One call. Keep the body to a single call into your own code, so the hook's listener is that call.
- One injection per moment. Two injections at a moment are two hook requests.
Counting server ticks in an attachment needs no game internals. Prefer an API point until the matching hook exists; use exactly the same API-only call in either form.
1. A point injection (the default). Game::tick is a point, so this builds on the prebuilt game. The
mod depends on horizon-mod-points as well as horizon-mod-api:
[[inject]]
id = "count"
at = "horizon_client::game::Game<'_>::tick"
kind = "head"
with = "tick"
hash = "98019f886ec32cc7"
pub fn tick(host: HostSide<'_>, _: &mut TickArgs) {
if let HostSide::Server(host) = host {
let _ = count(host);
}
}
#[mod_api::horizon_mod]
impl Hooks for Mod {}
2. The hook it becomes. Say the catalog has a hook at that moment (the server_tick hook, which fires every server tick, stands in here), so the injection disappears and the
mod implements the hook. The listener body is the same call:
#[mod_api::horizon_mod]
impl Hooks for Mod {
fn server_tick(&self, host: &mut dyn ServerHost, _: &mut ServerTickEvent<'_>) {
let _ = count(host);
}
}
fn count(host: &mut dyn ServerHost) -> Result<(), AttachmentError> {
let old = COUNTER.get(&mut *host, Holder::Level)?;
COUNTER.set(host, Holder::Level, old + 1)
}
The COUNTER attachment is the same in both:
/// A counter on the level, saved with the world.
static COUNTER: AttachmentType<i32> = AttachmentType {
namespace: "example",
name: "counter",
default: || 0,
codec: None,
copy_on_death: false,
sync: false,
};
Moving from the point to the hook keeps the same call: the function body becomes the hook's body, and its record becomes the hook's event. That is what "convertible" means, and why shape 2 is the default.
Ask for the hook
A gap is a hook request. File one as an issue on arismoko/horizon with the contract below. Copy this, and fill every field:
**Hook request: <short name>**
- **What the mod needs:** <one sentence: the moment, and what the mod does there>
- **NeoForge 26.3 counterpart:** <event or method name, or "none">
- **Vanilla class and method:** <`net.minecraft…Class.method`, the call the hook wraps>
- **The injection it replaces:** <the `[[inject]]` table, `at`, `kind`, `access` and callback signature>
- **Mod:** <name, repository or `.hmod`>
- **Side:** <server, client or both>
- **Contract wanted:** <listen, cancel, replace a value, collect; what a listener may change>
A hook with a NeoForge counterpart takes its name, side and contract. One without is welcome and names the mod mixin or use it replaces. State the vanilla method: the hook fires where vanilla calls it.
The build log
The launcher reports each unified declaration's target, access and selected slot/source route.
A source route means a game rebuild, not a fallback after failed freshness checks. Compiler
errors name the callback registration or source adapter. Inspect native/build.log for the
mod and inject id rather than guessing which callback filled a slot.
A panic inside a point listener reports the mod and the method
(in native mod <id>, hook <method>).
Support modules
A [[native.module]] table contributes a whole Rust module to a game crate, beside the injections:
self-contained items for source-level support code. It is the shape
for support code a moment needs and one function cannot hold — Portizon's runtime is the case that
asked for it.
[[native.module]]
id = "runtime"
file = "crates/horizon-client/src/game.rs"
name = "portizon"
source = "src/runtime.rs"
visibility = "pub" # optional: "pub" or "pub(crate)" (the default)
source holds the module's items without an outer wrapper; the tool emits them into file as
mod name. The destination file must carry no top-level glob import — horizon-mixins refuses one,
because it cannot prove which names the glob introduces — and the payload must be self-contained: no
mod items, #[path], include! or file attributes; assemble multi-file code into one file first.
Ids are unique across a package's injections and modules together.
Modules are native-only and last-resort support code, outside the mod_api-only
line: a mod's behaviour still goes through hooks, handles and Parts. Any capability a module needs
that the API lacks is a hook request in #44's row form, not a reason to grow the module.
Which functions are points
Every function of the game module is a point: Game methods, methods of its other types (GameLevel,
Levels…), associated functions and free functions. A crate below the game adds the functions
crates/horizon-mod-api-gen/slots/<mod_id>.txt lists, one path per line (horizon_client_render::chunk::Chunk::get);
each one there is measured first, since a slot on a hot function costs on every call. Left out:
- the hook functions themselves (
native_*), which are hooks; - a function compiled only under a
cfg, and one in acfg-gated module; const,asyncandunsafefunctions, and methods that takeselfby value;impl Traitor!in the signature,#[inline(always)], and a parameter namedcancelorresult.
A Game method that takes self has a point named after the method (tick). Any other is named by its full path, snake-cased
without horizon_client::: game_levels_levels_parked_dims for Levels::parked_dims, and for a lower
crate without horizon_: client_render_chunk_chunk_get, and Game::new is game_game_new. Game methods'
points get a host; free and associated functions have none.
A point name is always one snake-cased form, for engine and mod points alike. Snake-casing is not injective
(a::b_c and a_b::c are both a_b_c), so uniqueness comes from refusal: the build fails, naming both
functions, when two points have one name. A method of another type has no API
record until mod_api has a handle for that type; it keeps its internals form.
A method whose return value borrows the game (-> &T, -> Option<&mut T>) has a head and no API
return. A usize or isize crosses the record as u64 or i64, one width for native and wasm
listeners. A &[T] parameter is copied as a Vec<T>, and &str as text a listener may replace. A
value a mirror can't convert at a call skips that call's API listeners, logged; a listener's value
the engine refuses leaves the engine's own in place, logged. Mod input never panics the game.
points.json gives each point's api_refusal: absent when it has an API record, else the first
parameter with no API type, as the registration refusal states it.
The raw form (internals). A point whose signature has no type or const generics and no impl Trait also has a raw
form: a native listener that gets the real receiver and a &mut to every argument in its own type,
internal types included, with cancel or result to stop the body. Its return listener gets the value
returned, a borrowed one included (&mut &mut T), where the API form has no return. The launcher's
generated main registers raw listeners by type; declaring them in horizon-mod.toml comes with the
per-mod internals list.
points.json, beside the prebuilt game, lists them all; the rustdoc of horizon-mod-points documents
each with the function it listens at.
The source tree carries no #[point]: the build of the prebuilt game, and the launcher's source builds,
put one on each method in the copy they compile. A plain cargo build of the game in a checkout has no
points; a mod listening there builds, and its listeners never fire.
Mod points: patching another mod
A mod's own functions are points too (#2339),
by the same rules as the game's: every eligible function of the crate, public and private, free
functions, inherent methods and functions of inline modules (trait impl methods are not points). The
build compiles a staged copy with a point on each, in a slot table of the mod's own
(<crate>::__horizon_points); the author's source is never edited. A point is named by its path
from the crate root, snake-cased: tanks_tank_fill for chemlib::tanks::Tank::fill. Two functions whose
paths give one name (a::b_c and a_b::c) fail the build, naming both; rename one or opt it out.
A library's crate must be named after its mod id, with - as _ (chem-lib builds crate
chem_lib): an addon's manifest names the library by id and each at by crate, and on both
targets the points of a library whose crate is named otherwise are refused.
- Opting out.
#[mod_api::no_point]on a function gives it no point. An addon declaring a listener there is refused:chemlib::audit has no point (opted out with #[mod_api::no_point]). - Declaring. An addon depends on the library and pins the function with
cargo horizon-mod inject add chemlib::transfer --kind head --with double_rate, exactly as for the game's points:atis the function's path in its crate. access = "api". The listener gets the point's record,<crate>::__horizon_points::<Point>Args: a copy of every parameter,&mutones copied back, andcancelorresult. Every parameter needs aWireform: scalars, text, std containers,mod_api's types, and the library's own publicly reachable types that carry#[derive(mod_api::Wire)]. Nothing adds the derive; a parameter without one refuses the point's API listeners, naming it. A method of the library's own types has no API record (itsselfhas no handle), as on the game.- No Host. As on the game, only a
Gamemethod's point gets aHost; free functions and methods of other types get none, and a mod crate's functions have noGamereceiver. A mod point'sapilistener gets its record and nothing else; an addon that acts on the game does it from its own hooks, which get aHost. access = "internals". The listener gets the receiver and every parameter by reference, in their own types, through the raw form declared beside the function. It works from the library's own target: the same binary on native, the same dependency group on wasm. A wasm listener at a declared dependency's point needs no engine internals declaration. The group registers listeners through typed functions exported by the library, then seals every library registry before initialization; each member keeps its ownmod_apicopy.- The signature check. Each mod point's hash covers its path, its signature as written and its
record's field types. When the library's function changes, the addon is refused at load:
E_INJECT_CHANGED: addon:double_rate: chemlib 1.1.0 changed chemlib::transfer: pinned …, installed … (signature).cargo horizon-mod inject updatereports the change and repins with--accept. - Across targets. A native library with a wasm addon, or the reverse, is refused at load. A native
addon's listener at a wasm library's point says why it can never cross: "internals access to chemlib
needs the same target", or an
apilistener on a parameter without aWireform, naming it. Any other mixed edge gets the general refusal, "mixed native and wasm dependencies aren't supported yet" (Wire types). - Cost when nothing listens. One relaxed load of the place's slot and a branch at the head, one more for the return; the listened path stays out of line. On the chemicals fixture's per-tick transfer (a ~2 ns function) that is about 1.2 ns a call natively and 1.6–2 ns on wasm.
The build writes the mod's points to mod-points.json in a wasm package and pins its SHA-512 in
the manifest's [wasm] points; a package whose mod-points.json is missing, unpinned or changed is
refused.
The launcher lists a native mod's points from its source.
Raw listeners and declared internals
When an API-typed point cannot carry the state a mod needs, a native mod can declare game internals and a raw listener. Each internal is an API request: propose the missing hook, handle or Part on the roadmap so the mod can move back to the typed API.
internals = [
{ path = "horizon_client::game::Game", hash = "<written by the tool>" },
{ path = "horizon_client::game::native_ticks::TickRound::listening", kind = "method", hash = "<written by the tool>" },
]
[native]
game = "26.3"
horizon = "<exact game commit>"
mixins = "<exact mixins commit>"
[[inject]]
id = "count"
at = "horizon_client::game::Game<'_>::tick"
kind = "head"
with = "count"
access = "internals"
hash = "<written by inject add>"
Run cargo horizon-mod internals sync in the mod's folder after editing its Rust code. It
checks the mod against a discovery-only copy of the pinned game's source, uses compiler
warnings to find actual internal uses (including glob imports and macros), and replaces
top-level internals with their exact paths, kinds and hashes. Unused declarations are removed;
retained declarations keep their formatting. The command prints added and removed entries.
It is explicit: a normal build never changes the declarations reviewers and players read.
Install horizon-launcher on PATH, or set HORIZON_LAUNCHER to its executable. The command
invokes horizon-launcher native-build --sync-internals <mod folder> --trust-native-code, whose
stdout is a JSON list of discovered catalog entries for the tool. This runs cargo check, not
the game, but Rust build scripts and procedural macros execute with your rights: run it only
on code you trust. No installed game or instance is needed. The launcher caches the isolated
source and metadata build per pin under native/internals-sync/<commit>/; the second check
reuses those compiler outputs. Neither that cache nor its temporary diagnostic attributes is
used by normal builds or published prebuilt games. The source must be cached or downloadable;
E_INTERNAL_SOURCE_UNAVAILABLE explains when it cannot be obtained, without changing the
manifest or falling back to a different pin. Any compiler or catalog error also leaves the
manifest alone. Discovery checks the mod's library on the current host with default features;
configuration-gated catalog entries are still refused. Mods with source-spliced helper files
or source listeners are refused with E_INTERNAL_SOURCE_INJECTION: declare their internals
explicitly with internals add instead. This invocation stages one mod project; required mod
dependencies that are not in that project are refused by the dependency resolver, not silently
omitted.
Discovering module items
internals add, inject add and internals sync discover items in the mod's own
[[native.module]] tables. Module discovery downloads the pinned game source and uses the
launcher's managed Rust toolchain and pinned horizon-mixins, with the same module application
as a native source build. Install horizon-launcher on PATH or set HORIZON_LAUNCHER.
The cargo tools request the catalog pair using native-build --catalog --trust-native-code.
The launcher applies only this mod's modules to a fresh source copy before discovery staging;
it never applies modules to an already staged tree. Each invocation regenerates module catalogs,
so editing a module cannot reuse stale fingerprints. Paths refer to the destination game crate,
for example horizon_client::game::ported_level::PortedGame::tick, including in with callback declarations.
Mods without modules retain the pinned prebuilt catalog path for the cargo add commands.
Compiler discovery still refuses source listeners that require source rewriting.
cargo horizon-mod internals add <path> remains useful for those explicit declarations and
writes the path and hash from the pinned game's internals.json; don't invent hashes. When several member kinds share a path (such as a field
and a method named listening), pass --kind <kind> to select the catalog entry, for example
cargo horizon-mod internals add horizon_client::game::native_ticks::TickRound::listening --kind method.
The tool reports the available kinds if the choice is missing and writes the resolved kind
into each added declaration, even for unambiguous paths. A declaration may omit kind only
when its path has a single kind. Kind names match internals.json: inherent methods use method, free
functions use fn, and fields use field. A --kind applies to all paths in that invocation.
cargo horizon-mod update shows the signature changes and refreshes hashes while retaining
member kinds. Use cargo horizon-mod inject add to select the target and write a unified
injection with its function hash. with names the callback in the mod's crate root.
Depend on horizon-internals (library internals) at the same pin as horizon-mod-api, never
on horizon-client directly. The build generates a facade for this mod alone. Its nested
modules re-export declared types and free functions: the declaration above makes
internals::game::Game available. Declaring a field or inherent method exports its owner type;
a type necessarily brings all its public fields and inherent methods. Undeclared types have no
name in the facade and do not compile.
The facade is generated when the game builds the mod, and it re-exports items the prebuilt
game makes public; the game's source keeps them private. So a plain cargo check of the
mod resolves horizon-internals to an empty crate and can't see internals::…. Compile
a mod that declares internals with horizon-launcher native-build <mod folder>, which stages it
against the pinned game the same way the launcher does on install, and report its errors.
A raw injection also exposes the point's listener and raw record in internals::raw, for
example __TickListener and __TickRaw. Discovery keeps those same typed exports and does
not change [[inject]]; slot listeners need no special sync step. Implement pub fn count with the exact listener type
from points.json (including the receiver and argument-record lifetime). The facade's safe,
typed raw::tick constructor registers it in the generated executable; a mismatched function
fails at that registration line, naming the injection. return uses the point's return
listener and raw::tick_return constructor. Raw listeners work against the prebuilt game as
well as source builds; unlisted source points and modules require a game rebuild.
Native slot and source routes check the actual game's metadata before compiling mods:
E_INTERNAL_MISSING: the declared path is absent (type shapes not exposed as internals cannot be declared). Runcargo horizon-mod internals syncto discover this pin's used internals. Undeclared-item compiler errors also point to this command.E_INTERNAL_CHANGED: the hash changed; the error names the new signature and asks forcargo horizon-mod update.E_INTERNAL_AMBIGUOUS: several member kinds share the path; choose one of the listed kinds withinternals add <path> --kind <kind>.E_INTERNAL_GATED: the entry exists only under acfg; target-specific declarations are not supported.E_NOT_RAW: the injection's target has no raw form, for example because it is generic or takesimpl Trait.E_GAME_DEPENDENCY: the mod depends directly onhorizon-client; use the facade instead.E_INTERNAL_PATH: the entry cannot be re-exported as an item, for example an entire module; declare its types or free functions instead.
The build log lists each mod's internals and raw places. The same information is recorded in
native-mods.json beside the built game.
Listed call sites (native only)
An internals [[inject]] with call, nth and ctx wraps that exact call, not the enclosing function. Both native
build routes use the same points.json object: points lists function points and sites lists
call sites. Only reviewed call entries in crates/horizon-mod-api-gen/slots/<mod_id>.txt become sites:
horizon_client::game::Game<'_>::next_autosave_interval | autosave_interval | 0 | <ctx from points.json>
Each slot list accepts a function path, at | call | nth | ctx, or type <path> (a type listing; field declarations are source-only). Files support # comments; identical declarations merge, and conflicting
contexts name both files. The stage reads their sorted union, evaluates the receiver once, and rewrites
only the selected call expression in its copy of the game.
Call identity uses the resolved callee path, not its spelling: aliases and different receivers of the
same type share one ordinal sequence. Ordinals count runtime calls in source order; constant contexts
and nested item bodies are not selected. points.json publishes each site's resolved call, ctx,
and hash. The fingerprint includes the callee path and signatures. ctx hashes the containing
statement's tokens and its block/if/match-arm path, ignoring source formatting. Empty contexts are
for tool discovery only; copy the generated context when listing a slot, never invent one.
A unique matching context at another ordinal refuses with E_INJECT_MOVED, naming the new nth.
A changed or missing context refuses with E_INJECT_LOST. Two calls to the same callee with identical
contexts are also lost, even when nth still matches: the tool cannot prove which identical statement
was intended. Refresh a moved ordinal with inject update; reselect a lost call with inject add.
Function signature changes and missing functions use E_INJECT_CHANGED and E_INJECT_MISSING at the
unified routing boundary. E_INJECT_NO_SLOT reports an unsupported route or kind, with its reason
and requested slot declaration. E_INJECT_INVALID reports malformed declaration fields.
Freshness errors never silently select a different call.
[[inject]]
id = "autosave"
at = "horizon_client::game::Game<'_>::next_autosave_interval"
kind = "modify_value"
with = "autosave"
access = "internals"
hash = "<function hash written by inject add>"
call = "horizon_client::game::autosave_interval"
nth = 0
ctx = "<statement context written by inject add>"
The facade's internals::raw module exports this site's exact Args, Receiver, Output and
Listener aliases and a typed registration constructor, all named from the canonical callee (the same names on slot and source routes).
Use those aliases for the callback. The generated main adapts each kind to one wrap listener:
| Kind | Callback inputs and result |
|---|---|
before_call | Receiver and mutable arguments; runs before the original call |
after_call | Receiver and a shared reference to the result; keeps that result |
modify_value | Receiver and the result by value; returns the replacement |
wrap_call | Receiver, mutable arguments and mutable original continuation; returns the result |
wrap_if | Receiver and shared arguments; returns whether to call the original; unit output only |
Native source internals calls also admit immutable borrowed arguments, such as &str.
Their generated Args<'a> record borrows the original values for the call; no implicit
owned-string conversion occurs. The listener is higher-ranked over that lifetime, so safe
callbacks cannot retain the record or its references beyond the call, or mutate the referent
through an immutable reference (ordinary interior mutability still follows Rust's rules).
A callback may replace an argument with another reference valid for the same lifetime.
Engine types are borrowed too: &MobWorld becomes a shared reference to the native
horizon_client_lib::mobs::MobWorld<'g> in Args<'g, 'a>, keeping the world's own
lifetime separate from the record's borrow. The facade re-exports the typed record;
it does not convert the world into a mod API value or expose private fields. A callback
can read the world's public fields and call its shared-reference methods using Rust's
normal field projection, including public nested values, even without naming every type.
Naming MobWorld itself requires its internals facade export. It cannot obtain exclusive
access or extend either lifetime. Copy arrays of scalar values, such as [i32; 3], can
accompany these references in source records.
modify_value still receives only the receiver and owned result, not the argument record.
Mutable borrowed arguments remain refused, as do borrowed arguments on API/listed routes;
this does not enable API source adapters or wasm call sites.
Owned arguments on native source calls
A native source internals call can also move owned arguments, such as the ItemStack
in Game::dispense_item, into its record. Argument expressions run once in their original
order. No stack is implicitly cloned. A record with any owned non-Copy field selects the
single-use listener contract; named value types are conservatively treated as owned,
even when that particular type implements Copy. Non-generic private aliases are expanded
from the catalog, so the record names the actual field types rather than an inaccessible alias
in another implementation module. The existing scalar/shared-reference-only
contract remains repeatable.
before_call still receives &mut Args, allowing argument modification; an observing
callback can inspect its fields through shared references. after_call and modify_value
keep their result-only signatures. A single-use wrap_call instead receives Args by
value and the site's generated Original<'g, 'a, 'next> capability (exported beside
Args, Receiver, Output and Listener under the same canonical prefix):
pub fn wrap(receiver: Receiver<'_, '_>, mut args: Args, original: Original<'_, '_, '_>) -> Output {
inspect(&args.stack);
modify(&mut args.stack);
original.call(receiver, args)
}
original.call consumes both the forwarding capability and the record. A second call,
using the moved record again, or retaining the capability past this call fails to compile.
A wrapper that does not forward must supply its own correctly typed output; its unconsumed
arguments drop normally. wrap_if remains unit-output-only and drops the arguments when
its condition is false. Panics unwind through ordinary Rust ownership: a value is not leaked
or dropped twice by the engine, whether the listener panics before or after forwarding.
No per-call heap allocation is needed for the capability.
Borrowed and owned fields share one lifetime-bearing record. Forwarding moves owned fields
and copies shared references; dropping the record does not drop their referents. Native
internals callbacks are trusted code, not a sandbox: a callback may explicitly take an
owned value through replacement or by owning Args, and may call mem::forget. The engine
promises no hidden copies or leaks; it cannot prohibit these explicit mod-side transfers.
Mutable-reference arguments, listed/prebuilt owned records and API/wasm routes remain refused.
Owned shapes containing further borrows remain refused; direct immutable-reference fields
can accompany owned fields.
Listeners nest by descending priority, then mod ID, then injection ID; the first is outermost.
A repeatable wrap may call original zero, one or several times; a single-use wrap forwards
at most once. cancellable is refused: returning from the caller is not a call-site operation. Borrowing results, non-record arguments, ambiguous targets
and alias-dependent receiver arguments are refused with the declaration's file, line and reason.
An unlisted canonical call can use native source routing after the same freshness checks.
A listed stale call never falls back to source. Use the function hash and statement context,
not a site's physical fingerprint, as the declaration identity. wrap_if needs unit output.
These call kinds are not available to wasm mods.
From mixins and legacy kinds
Each Mixin injector, and each kind of the removed [[native.injection]] helper form, has one
[[inject]] form. Two rules come first:
- A hook function is not a point: implement the hook. Where the catalog has a hook for the
moment, a mixin there becomes a
mod_api::Hooksmethod, not an injection.native_*functions are left out of points on purpose. - Mixins are internals. Call-site kinds, internals
replaceand the raw listeners needaccess = "internals"and are native only: amod.wasmdeclaring them is refused at load. Onlyhead,returnandreplacehave anaccess = "api"form.
at, hash, call, nth and ctx in the examples are written by cargo horizon-mod inject add.
Each callback's types come from the mod's facade (internals::raw).
| Mixin injector | Legacy kind | [[inject]] form | Example |
|---|---|---|---|
@Inject(at = @At("HEAD")) | HEAD | kind = "head" | head |
@Inject(at = @At("RETURN")), TAIL | RETURN | kind = "return" | return |
@Inject(at = @At("INVOKE")) | BEFORE_CALL | kind = "before_call" | before_call |
@Inject(at = @At(value = "INVOKE", shift = AFTER)) | AFTER_CALL | kind = "after_call" | after_call |
@Inject(at = @At("INVOKE_ASSIGN")) | AFTER_ASSIGN | kind = "after_call" (read), "modify_value" (change) | after-assign |
@Overwrite | OVERWRITE | kind = "replace" | replace |
@ModifyArg | MODIFY_ARG | kind = "before_call" | one call argument |
@ModifyArgs | MODIFY_ARGS | kind = "before_call" | all call arguments |
@ModifyVariable on a parameter | MODIFY_VARIABLE | kind = "head" | a parameter |
@ModifyVariable on a local | MODIFY_VARIABLE | kind = "modify_local" | a local |
@ModifyConstant | MODIFY_CONSTANT | kind = "expression" | constants and field reads |
@ModifyExpressionValue on a call | MODIFY_EXPRESSION_VALUE | kind = "modify_value" | modify_value |
@ModifyExpressionValue on a field read | MODIFY_EXPRESSION_VALUE | kind = "expression" | constants and field reads |
@WrapOperation, @Redirect | WRAP_OPERATION | kind = "wrap_call" | wrap_call |
@WrapWithCondition | WrapWithCondition | kind = "wrap_if" | wrap_if |
@Unique fields | state fields | kind = "field" (pending: #2582) | state |
| support classes | support modules | [[native.module]] | support classes |
@Shadow, @Accessor, @Invoker | bindings | declared internals | bindings |
"Pending" kinds parse, and are refused with a typed E_INJECT_NO_SLOT reason until the PR named
lands.
The call kinds below share one site; this declaration is written for each (only kind and with
change):
[[inject]]
id = "autosave"
at = "horizon_client::game::Game<'_>::next_autosave_interval"
kind = "before_call"
with = "autosave"
access = "internals"
hash = "<function hash>"
call = "horizon_client::game::autosave_interval"
nth = 0
ctx = "<statement context>"
The site's aliases are named from the callee (here __…AutosaveIntervalReceiver, …Args, …Output);
the examples shorten them to Receiver, Args and Output.
head
Runs before the body, with every argument. The API form gets the side's host and the point's record;
the internals form gets the receiver and the raw record. Setting cancel (or result) skips the body.
[[inject]]
id = "tick"
at = "horizon_client::game::Game<'_>::tick"
kind = "head"
with = "tick"
hash = "<function hash>"
pub fn tick(host: mod_api::HostSide<'_>, args: &mut mod_points::TickArgs) {
my_mod::count(host);
}
return
Runs after the body with the value returned, which it may change (args.result in the API form, the
value itself in the raw form). kind = "return", otherwise as head:
pub fn wrap(args: &mut mod_points::GameCommandsWrapDegreesArgs) {
if let Some(angle) = &mut args.result {
*angle = angle.round();
}
}
before_call
Runs just before the call, after its arguments are evaluated, with the receiver and the arguments mutably:
pub fn autosave(receiver: Receiver<'_, '_>, args: &mut Args) {
my_mod::note_rate(args.rate);
}
after_call
Runs just after the call with its result, which the caller still gets unchanged (kind = "after_call"):
pub fn autosave(receiver: Receiver<'_, '_>, value: &Output) {
my_mod::record_interval(*value);
}
after-assign
INVOKE_ASSIGN fires right after the call's value is stored into a local. In Rust,
let interval = autosave_interval(…); moves the value into the binding with nothing in between, so
after_call sees exactly the value assigned, and modify_value changes
what is assigned. Other locals in scope are modify_local's.
modify_value
Takes the call's result by value and returns the one the caller gets (kind = "modify_value"):
pub fn autosave(receiver: Receiver<'_, '_>, value: Output) -> Output {
value * 2
}
replace
Owns the function: the body never runs, and the function's value is the listener's. One replace per
point, whatever the tier. The API form takes the record and returns the record's result type;
the internals form takes the receiver and raw record and returns the function's own type:
[[inject]]
id = "average"
at = "horizon_client::game::blocks::beacon::average"
kind = "replace"
with = "average"
access = "internals"
hash = "<function hash>"
pub fn average(raw: &mut internals::raw::__GameBeaconAverageRaw<'_, u32, u32, u32>) -> u32 {
(*raw.a).max(*raw.b)
}
Unlike a mixin @WrapOperation, a replace has no original to continue into: the body is gone for
as long as it owns the point. Calling the replaced function from the listener doesn't reach the
body; it enters the point again, runs the replace again and recurses until the stack overflows. Copy
the part of the body you need into the listener instead.
one call argument
@ModifyArg changes one argument of a call: a before_call writing that field.
pub fn autosave(receiver: Receiver<'_, '_>, args: &mut Args) {
args.rate = args.rate.max(20.0);
}
all call arguments
@ModifyArgs gets them all: the same before_call, writing any of args' fields
(autosave_interval has one, rate).
pub fn autosave(receiver: Receiver<'_, '_>, args: &mut Args) {
args.rate *= 2.0; // and any other field of `args`
}
a parameter
@ModifyVariable on a parameter at the head is a head listener writing that argument:
pub fn turn_angle(args: &mut mod_points::GameCommandsWrapDegreesArgs) {
args.angle += 180.0;
}
a local
@ModifyVariable on a local is kind = "modify_local": the listener gets the binding itself, before
or after a selected call (mutable locals).
constants and field reads
@ModifyConstant, and @ModifyExpressionValue on a field read, are kind = "expression": the
listener takes the selected value and returns the one used
(expressions).
wrap_call
Wraps the call: the listener decides whether and how often the original runs, and with which
arguments (kind = "wrap_call"):
pub fn autosave(
receiver: Receiver<'_, '_>,
args: &mut Args,
original: &mut dyn FnMut(Receiver<'_, '_>, &mut Args) -> Output,
) -> Output {
if my_mod::paused() { 0 } else { original(receiver, args) }
}
wrap_if
Runs the call only when the listener returns true (kind = "wrap_if"). The call must return (),
so this one sits at a unit call, not at autosave_interval:
pub fn play(receiver: Receiver<'_, '_>, args: &Args) -> bool {
!my_mod::muted()
}
state
A @Unique field becomes kind = "field", an extension field on a listed type. It lands with
#2582; until then it is refused with a typed reason. For data a mod keeps on game objects today,
use attachments.
support classes
Helper classes a mixin calls become a [[native.module]] table: whole Rust items
emitted into a game crate, native only.
bindings
@Shadow, @Accessor and @Invoker reach private members. Declare them as
internals and name them through the facade:
internals = [{ path = "horizon_client::game::Game", hash = "<written by the tool>" }]
pub fn tick(game: &mut internals::game::Game<'_>, raw: &mut internals::raw::__TickRaw<'_>) {
my_mod::observe(&game.world_root); // a field of the declared type, reached directly
}
Reference
- A point's methods are
<point>(its head: the arguments it was called with) and<point>_return(after its body: the value it returns), in a crate-rootwithfunction. Its record ismod_points::<Point>Args. - Depend on
horizon-mod-pointsonly to listen at points. It holds one record per point; a mod without point injections leaves this dependency out. - A listener's host is a
HostSideon a function that takes&mut self, and&dyn Hoston one that takes&self. - The game sets a point's slots once, after the mods are installed; a point no mod listens at costs one atomic load and a branch.
- A point injection's
hook, bindings and horizon-mixins fields are refused: a hook is its ownHooksmethod, and the record carries what the point passes. [[inject]]has no binding or hook-version fields. Implement a catalog hook throughHooks; use the slot's generated callback signature for an injection.
Delegated knockback entries
Knockback preserves two independently injectable function entries. The short entry calls
its full entry once with the effect flag false; a direct full-entry call bypasses the short
entry. Thus two head listeners that each double strength apply twice through the short
entry and once through the full entry. Canceling the short entry prevents the full call.
This does not add a hook: living_knockback remains one observation per ordinary physical
push, before resistance. Sulfur cube effect launches retain their species-specific handling.
The native internals targets are:
horizon_client_lib::mobs::knockback::Mob::knockbackhorizon_client_lib::mobs::knockback::Mob::knockback_fullhorizon_client::game::creatures::knockback::Game<'_>::knock_back_playerhorizon_client::game::creatures::knockback::Game<'_>::knock_back_player_full
Default hurt and sweep pushes use the short entry. causeExtraKnockback, including both
spear pushes, uses the full entry with its corresponding effect flag. The player full entry
carries damage and the flag even though vanilla player physics does not use them.
These entries are not admitted API points yet. The mob receiver Mob has no point handle;
DamageLevel and Damage have no point mirrors. The player receiver becomes Host, but
HurtSource and Attack have no point mirrors. Registration refuses the first unadmitted
type on both native and wasm. General admission is tracked in
#2767. access = "internals" is native-only;
wasm refuses it at load, not a silent fallback. Native injection tests do not establish wasm
entry support while those API refusals remain.
Mutable locals (native source rebuild only)
modify_local requires access = "internals" and a rebuild from source. It uses the
same pinned call, nth and ctx identity as other call-site injections, with three
required fields. local and position belong only to this kind; type is also used by field:
[[inject]]
id = "adjust_count"
at = "horizon_client::game::count"
kind = "modify_local"
access = "internals"
with = "adjust_count"
hash = "<function hash>"
call = "horizon_client::game::sink"
nth = 0
ctx = "<statement context>"
local = "n"
type = "i32"
position = "before" # or "after"
The callback fn adjust_count(n: &mut i32) receives the actual mutable binding.
Before callbacks run before evaluating the call; after callbacks run after it returns,
while preserving its result. There is no copy or deferred write-back. Listeners use
the existing priority/mod/injection ordering at each position. They may mutate the
value directly, with the same type and panic handling as native internals listeners.
No Host is passed by this raw internals route.
The initial implementation accepts direct call statements in the enclosing function
block. It refuses nested expression positions whose scope or evaluation order cannot
be proven. The binding must be a single mutable local with a matching explicit Rust
type, or a directly initialized suffixed integer literal (such as 3i32) or boolean
literal. Unsuffixed integer literals are refused because their inferred type cannot
be proven. The declaration must include an initializer; later assignments do not
establish initialization for this analysis.
Missing, shadowed, immutable, type-mismatched, moved or borrowed bindings are refused
by name. Non-scalar uses before the position, references (including ref/ref mut
patterns in let, match and if-let over expressions mentioning the local), closures
and opaque macros are conservatively refused when their dataflow cannot be proven.
This may refuse bindings that Rust's borrow checker could otherwise accept.
API access and wasm permanently refuse mutable locals; use native internals or request a hook. Wasm also permanently refuses call-site injections. Nothing is rewritten when no mod declares a local injection, so an unused local injection has zero runtime cost.
Transient native fields
kind = "field" adds a real public Rust field during a native source rebuild:
[[inject]]
id = "count"
at = "horizon_client::game::Game"
hash = "<target struct hash from internals.json>"
kind = "field"
access = "internals"
with = "count"
type = "u32"
The emitted member is <mod_id>__<with> (a mod id starting with a digit gets a leading underscore).
Declare the target through the existing internals facade and access the member directly,
for example game.example__count. A declaration colliding with an engine field is refused.
There is no listener and no runtime registration for this kind.
The field type must implement Default and, when the owner clones, Clone. Rust checks
these bounds during the source build. Types must already be reachable from the target engine
crate: standard-library types and visible engine types, using canonical engine paths.
Mod-defined types are refused by name; use attachments for them.
Struct literals initialize the member with Default::default(); struct update expressions
retain the member from their base. Derived Default and Clone include the member automatically.
Manual Default constructors initialize it, and manual Clone and clone_from copy it from the source.
Exhaustive patterns, macro constructions, explicit representation layouts and unsupported codec
derives refuse by target name rather than guessing how to extend them. Serde derives receive
#[serde(skip)]; Wire and unknown derives refuse. Explicit save and sync field lists are untouched.
The field is never saved or synced. Use attachments for persistence or synchronization.
API access and wasm permanently refuse this kind and point to attachments. No declarations means no rewrite and zero runtime cost. A declared field adds its size (and layout alignment) to every instance; Default and Clone perform the field type's normal operations.
Injected mod fields must not change engine equality, ordering or hashing. Targets deriving
PartialEq, Eq, PartialOrd, Ord or Hash are currently refused with E_INJECT_FIELD;
a neutral comparison wrapper is follow-up work. Hand-written comparison implementations
ignore the injected field. Debug, Clone and Default keep their normal field behavior.
Expressions (native source rebuild only)
expression replaces one value the function reads: a literal (64.0, -1, "name"), a
constant's path (MAX_REACH) or a field read (self.speed). It requires
access = "internals" and a rebuild from source, and takes expr, type, nth and ctx;
call, local and position are refused:
[[inject]]
id = "farther"
at = "horizon_client::game::Game<'_>::reach"
kind = "expression"
access = "internals"
with = "farther"
hash = "<function hash>"
expr = "64.0"
type = "f64"
nth = 0
ctx = "<statement context>"
The callback fn farther(value: f64) -> f64 gets the value the game would use and returns the
one it uses instead; listeners chain in the usual priority order, and no Host is passed. expr
is matched as Rust tokens, so spacing doesn't matter. nth counts the matching reads in the
function from 0, in source order, and ctx pins the statement it sits in, as for call sites.
Reads a value can't stand in for are not counted: an assignment's place, a borrow's operand
(&LIMIT) and a method's receiver (4u32.max(1)), along with constant contexts and nested items.
type is the value's Rust type: a copied scalar (i8 … u128, isize, usize, f32, f64,
bool, char) or &'static str. A suffixed literal whose suffix differs is refused before the
build. An unsuffixed literal, a constant or a field takes the declared type as written; when the
game uses another type there, the build fails with E_INJECT_EXPRESSION_TYPE, naming each mod
and inject id that declared the site, the type, the expression and the function.
When the function changes and ctx no longer matches, the refusal (E_INJECT_LOST) lists every
matching read now in the function as nth = …, ctx = "…", so the declaration can be fixed without
staging the game. Mods declaring the same read with the same type share one site. API access and
wasm permanently refuse it; use native internals or request a hook.
Biomes
What a mod can do with biomes, by what it wants. Each row names the call or file; the pages it links to give the detail.
| I want to | Use | Side |
|---|---|---|
| Add a biome | data/<ns>/worldgen/biome/<name>.json in a content mod or data pack (content mods) | Content |
| Add my features or spawns to vanilla's biomes | A biome modifier file in data/<ns>/neoforge/biome_modifier/ (content mods) | Content |
| Give my dimension its own biomes | DimensionSettings::biome_source: Fixed, Checkerboard, MultiNoise by preset, End, or Points for an explicit climate-point layout (dimension shape) | Both |
| Know the biome at a position | LevelRef::biome_at on a server level, LevelRef::client_biome on a client | Server, client |
| Find the nearest biome or tag | LevelRef::find_closest_biome with a BiomeSearch | Server |
| Change a biome | LevelRef::set_biome, one 4x4x4 cell, as /fillbiome does | Server |
| Run bone meal at a position | LevelRef::bone_meal | Server |
| Tint foliage, grass or water by biome | The register_color_resolvers hook for new colour resolvers, and the biome_colors hook to change a biome's own colours | Client |
Carver, climate and effects modifier support is implemented; that does not imply full custom serializers or phase ordering.
What is not here yet
- Placing a mod's own biomes among the Overworld's or the Nether's: a design note, not code (arismoko/horizon#2554).
- Full custom biome-modifier serializers and explicit phases between modifiers (#2732).
- A mod's own worldgen registry loaded into the world (#811), and per-biome client effects (#2734).
- Features and placers defined in code (#815).
Some of these have a PR open; a page for each lands with it.
Reads and writes are limited on purpose
biome_atnever loads or generates a chunk: an unloaded column answersNone.find_closest_biomefollows vanilla's sampled spiral, so it is not a search through cells a mod edited, and it refuses a search whose sample count passesBiomeSearch::MAX_SAMPLES.set_biomerefuses a client, an unknown biome and an unchanged cell, and returns false rather than an error.
Building a mod to mod.wasm
Boundary benchmark
From the root of the worktree containing the fixture and runtime to measure, build the tool, fixture,
and shared std, then run the release benchmark. This requires rust-src and the
wasm32-unknown-unknown target for the pinned wasm toolchain:
cargo build -p cargo-horizon-mod
(cd crates/horizon-mod-wasm/tests/fixture && "$OLDPWD/target/debug/cargo-horizon-mod" horizon-mod build --wasm --catalog points.json && "$OLDPWD/target/debug/cargo-horizon-mod" horizon-mod wasm-std "$OLDPWD/target/horizon-wasm-std.wasm" --catalog points.json)
HORIZON_WASM_STD="$PWD/target/horizon-wasm-std.wasm" HORIZON_WASM_FIXTURE="$PWD/crates/horizon-mod-wasm/tests/fixture/target/horizon-mod/wasm_fixture.hmod" cargo test --release -p horizon-mod-wasm --features listener-fixture --test bench empty_subscribed_listener -- --ignored --exact --nocapture
Building the fixture rewrites crates/horizon-mod-wasm/tests/fixture/horizon-mod.toml with the
generated wasm hash and toolchain identity. Inspect and discard those generated manifest changes
after measurement rather than committing toolchain-specific hashes; preserve any intended edits.
Keep the machine, build profile and conditions consistent when comparing revisions.
The empty-listener benchmark dispatches the same subscribed server_stopped listener natively and
in wasm. It reports 100 samples of 100,000 dispatches after 20 warm-up samples, with loading,
initialization, subscription installation and the in-memory host outside timing. It is not a
server teardown or world tick. The existing block_entity_ticks case measures 1,000 in-memory
block entity ticks per sample, with and without two host reads per tick; replace
empty_subscribed_listener with block_entity_ticks in the command to run it. The wasm-mods
workflow runs that case only on manual dispatch.
For an outer wasm call, an immediately available group lock does not start the busy timer. On contention, non-worker calls wait up to five seconds from the first failed lock attempt; they return busy immediately if the group is lent to another thread. Worker calls continue waiting instead of using that timeout or lending refusal. A synchronous same-thread reentrant call uses the lent context and its enclosing deadline; frame, resource and trap cleanup still apply.
A mod written against mod_api builds either way from the same source: native, compiled into
the game by the launcher (Native mods), or to mod.wasm, which any game with
the same mod API runs with no rebuild. Only the build step differs: the hooks, Parts,
[dependencies] and the manifest mean the same for both.
use mod_api::{
BlockKey, DataLoadContext, EffectKey, Flow, FluidDefinition, FluidIngredient, FluidKey,
FluidStack, FluidStateError, Hooks, HostFluidStates, HostSide, ItemPart, LootTableLoadEvent,
Registrar, RegistrationContext, ServerChatEvent, ServerHost, StateConstraint, Text,
UseItemEvent,
};
use mod_points::TickArgs;
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Default)]
pub struct Mod {
stone: std::sync::OnceLock<BlockKey>,
speed: std::sync::OnceLock<EffectKey>,
water: std::sync::OnceLock<FluidKey>,
chocolate: std::sync::OnceLock<FluidKey>,
}
/// Sticks announce each use.
struct Stick;
#[mod_api::horizon_part]
impl ItemPart for Stick {
fn use_item(
&self,
host: HostSide<'_>,
event: &mut UseItemEvent<'_>,
) -> Option<mod_api::InteractionResult> {
if let HostSide::Server(host) = host {
let item = event.stack.item().id(host);
host.messages()
.broadcast(Text::literal(format!("a {item} was used")));
}
None
}
}
#[mod_api::horizon_mod]
impl Hooks for Mod {
fn register(&self, registrar: &mut dyn Registrar, _cx: &RegistrationContext) {
let item = registrar.item_key("minecraft:stick");
registrar.item_part(item, Stick);
let _ = self.stone.set(registrar.block_key("minecraft:stone"));
let _ = self.speed.set(registrar.effect_key("minecraft:speed"));
let _ = self.water.set(registrar.fluid_key("minecraft:water"));
let _ = self.chocolate.set(registrar.declare_fluid("chocolate"));
}
fn loot_table_load(&self, context: &dyn DataLoadContext, event: &mut LootTableLoadEvent<'_>) {
if event.id != "test:loot" {
return;
}
let water = context.fluid_key("minecraft:water").expect("water");
let ingredient =
FluidIngredient::from_json(&mod_api::serde_json::json!("minecraft:water"), context)
.expect("ingredient");
assert!(ingredient.test(&FluidStack::new(water, 1)));
*event.table = mod_api::serde_json::json!({"fluid_guest_ok": true});
}
fn server_chat(&self, host: &mut dyn ServerHost, event: &mut ServerChatEvent<'_>) -> Flow {
let stone = *self.stone.get().expect("registered block key");
let speed = *self.speed.get().expect("registered effect key");
assert_eq!(stone.id(host), "minecraft:stone");
assert_eq!(speed.id(host), "minecraft:speed");
let state = host.block_state_default(stone).expect("stone state");
let fluid = host.state_fluid_state(state).expect("fluid state");
assert_eq!(
(
fluid.amount,
fluid.falling,
fluid.is_source(),
fluid.is_empty()
),
(0, false, false, true)
);
let service = host.fluid_states();
let water = *self.water.get().expect("registered water");
let chocolate = *self.chocolate.get().expect("declared chocolate");
let Some(FluidDefinition::World {
default,
properties,
}) = service.definition(water)
else {
panic!("water definition")
};
assert!(default.matches(&[StateConstraint::new("falling", "true")]));
assert!(
properties
.iter()
.any(|p| p.name == "falling" && p.values.contains(&"false".into()))
);
assert_eq!(service.validate(water, &[]), Ok(()));
assert_eq!(
service.validate(water, &[StateConstraint::new("level", "8")]),
Err(FluidStateError::UnknownProperty(0))
);
assert_eq!(
service.validate(
water,
&[
StateConstraint::new("falling", "true"),
StateConstraint::new("falling", "true")
]
),
Err(FluidStateError::Duplicate(1))
);
let properties = service.type_properties(water).expect("water properties");
assert_eq!(properties.description_id, "block.minecraft.water");
assert_eq!(properties.light_level, 3);
assert_eq!(properties.fall_distance_modifier, 0.25);
assert!(properties.can_push_entity && properties.can_drown && properties.can_hydrate);
assert!(!properties.can_swim && !properties.can_extinguish && !properties.supports_boating);
assert_eq!(properties.explosion_resistance, 7.5);
assert!(properties.randomly_ticks);
assert_eq!(
properties.drip_particle.as_deref(),
Some("sample:dripping_water")
);
// The sample host answers 1000 plus the amount (8) plus one for a falling state.
assert_eq!(
service.legacy_block(default).map(|state| state.0),
Some(1009)
);
assert_eq!(service.type_properties(chocolate), None);
assert_eq!(
service.definition(chocolate),
Some(FluidDefinition::TankOnly)
);
assert_eq!(
service.validate(chocolate, &[]),
Err(FluidStateError::NoWorldState)
);
*event.message = Text::literal(format!("{} (via mod.wasm)", event.raw_text));
Flow::Continue
}
}
static TICKS: AtomicU64 = AtomicU64::new(0);
/// Listens at the game's tick, a point its `horizon-mod.toml` names: every 200 server ticks it
/// says so in chat.
pub fn tick(host: HostSide<'_>, _args: &mut TickArgs) {
let HostSide::Server(host) = host else { return };
let ticks = TICKS.fetch_add(1, Ordering::Relaxed) + 1;
if ticks % 200 == 0 {
host.messages()
.broadcast(Text::literal(format!("hello_wasm: {ticks} server ticks")));
}
}
#[cfg(target_arch = "wasm32")]
include!(concat!(env!("OUT_DIR"), "/guest_injects.rs"));
Owned values and Wire
Native and wasm use the same owned-value encoding. A mod chooses which of its own
values may cross that boundary by explicitly deriving mod_api::Wire:
#![allow(unused)] fn main() { #[derive(mod_api::Wire)] struct CounterUpdate { counter: mod_api::Identifier, value: u32, } }
Records write fields in declaration order; enum tags are zero-based declaration indices, not Rust discriminants. Each field must have a Wire form. Borrowed hook arguments are call-table views, not owned Wire values; a borrowed record cannot use this derive. The derive is never added automatically to a mod's types.
For a value with checked construction, #[wire(via = SomeOwnedType)] uses
From<&Self> to encode and TryFrom<SomeOwnedType> to decode. A failed conversion
refuses the value rather than constructing unchecked state. This also lets a type
retain a deliberately chosen encoding instead of exposing its Rust fields.
The API's own plain values use this same derive, including particle options, package descriptions, handles and reload file collections. Checked registry keys and item stacks retain codecs that validate host registry slots, nonzero stack counts and component patches. A declaration retaining a handwritten codec states its particular validation, projection or nonstandard byte format; merely having a handwritten codec is not an exception to the explicit Wire policy.
The loader compares composed encoding layouts, including nested fields and
recursive shapes. Equivalent standard representations share one encoding:
Vec<T> and Box<[T]>, for example, are both lists, while Box<T> is transparent.
Changing a field's encoded form changes its callers' layout too. Layouts are
computed once at load; they are not per-tick work.
Build
cargo horizon-mod build --wasm
It builds the crate with the pinned Rust toolchain (installed through rustup the first time, with
rust-src), writes mod.wasm beside horizon-mod.toml, records it in a [wasm] section, and
packs target/horizon-mod/<id>.hmod. [wasm] is written by the tool, never by hand:
[wasm]
sha512 = "…" # mod.wasm's hash, checked when the game loads it
toolchain = "1.99.1 <commit>" # every mod linked together needs the same one
[wasm.linked] # the exact builds of its wasm dependencies
my_library = "…"
A mod carries native code or wasm code, not both. A wasm mod has no [native] table.
A mod with [[inject]] listeners builds against the game's staged point catalog. Pass
--catalog path/to/points.json, or set HORIZON_WASM_STD to the toolchain's std member
with points.json beside it. Mods without listeners need no catalog. When packaging the
wasm toolchain, cargo horizon-mod wasm-std <out.wasm> --catalog <staged-points.json>
copies that catalog beside the std member.
The std member also owns the group's shared state: its cache budget and the one table of handlers (capability providers and parts) that the mods of a group give the game, so a handler one mod of a group gave is usable by another mod of the same group. A mod built for a toolchain whose std lacks that table is refused at load; rebuild the mod and the std together.
The state is 32 words, zeroed, exported by the std member as horizon_group_state: the handler table, the cache budget, and
29 slots for eviction callbacks. A member's allocator is mod_api's #[global_allocator] on wasm32. When an allocation
fails because the group's memory (256 MiB) is full, it calls every callback registered with
mod_api::wire::guest::register_eviction(fn()) once and retries once; if that frees nothing the allocation fails as
usual (try_reserve returns an error, an infallible allocation ends the call with a trap). Register a callback to
give back what a cache can rebuild. It must free memory and must not allocate. Registering the same function twice is
harmless; false means all 29 slots are taken.
A wasm mod must not declare its own #[global_allocator]: cargo horizon-mod build --wasm fails with rustc's error
"the #[global_allocator] in this crate conflicts with global allocator in: mod_api". Allocate through the standard
collections and let the group's allocator do the rest.
Dependencies
[dependencies] work as for native mods. List the library mod in horizon-mod.toml and its
crate in Cargo.toml. The tool stages the resolved mod packages into one Cargo workspace:
each library's single compiler unit produces both its rlib and its position-independent member,
and dependents use that exact rlib through Cargo's ordinary dependency edges. Cargo still owns
feature resolution, build scripts and non-mod dependencies. Registration generation is scoped to
each package, not to the mod from which the command was launched.
Ordinary Rust calls such as my_library::function(), statics, generics and owned Rust types need
no generated interface or export annotations. Members export their public definitions without
symbol interposition: duplicate definitions are refused, rather than overriding another mod.
Each member records its compiler, target, settings,
rlib digest and dependency artifact identities in horizon.build metadata covered by its module
hash. Mod crate disambiguators use the mod ID, version and resolved Cargo features rather than
the temporary workspace path. A shared crate disambiguator alone is not an ABI guarantee:
the loader checks the exact dependency build recorded in [wasm.linked], including when the
library and its consumers were built separately. Changing a library's source, toolchain,
features or settings requires rebuilding its consumers.
The build packages every member of the resolved group into the invoking project's
target/horizon-mod/ directory. A consumer build does not replace another project's previously
packaged .hmod. To include additional local roots in the same Cargo invocation, repeat
--with path/to/another/mod. The game links a mod with its
dependencies at load, one copy of each library shared by every mod that uses it, and refuses a
mod whose library is missing or another build than the one it was linked against, naming it on
the Mods page. An optional dependency is built in with the Cargo feature of its id, so its crate's
entry in Cargo.toml is optional = true under that id; an absent one is left out of the group at
load, and if_installed! keeps the mod from calling it.
Injection points
API-typed [[inject]] listeners use the same generated mod_points records and constructors as
native listeners. When injections are declared, the wasm build requires the pinned game's staged
points.json, supplied via --catalog or beside the configured std member.
Listeners register in the host's ordinary point list, with the same priority, dependency order,
owner enable flag and stop behavior as native listeners. Mixins remain native-only.
A point's record is a copy of every parameter as an API value, &mut parameters copied back
after the listener, exactly as for a native listener; a point with a parameter that has no API type
yet refuses an api listener at registration, naming it. access = "internals" (raw records,
call kinds, support modules) is native-only: a mod.wasm declaring [[internals]] or an internals
injection is refused at load with "internals access is native-only".
ABI reference
A dependency group consists of core wasm shared modules with dylink.0, not components.
The loader provides one linear memory, function table and standard-library member per group,
plus the env base globals and the GOT.mem and GOT.func relocations used by the members.
The loader supplies linker globals such as GOT.mem.__heap_base and GOT.mem.__data_end;
member constructors run after linking. Groups do not share memory with one another.
The build step moves a core start function to the explicit __wasm_start export. The loader
instantiates members, fills self-referencing GOT entries from their actual exports, then runs
__wasm_start, data relocations and constructors in that order. A raw core start is refused:
it would execute before its own addresses could be relocated. horizon.private metadata lists
compiler implementation exports that remain local to a member (for example an upstream inline
method emitted in several members). Their original symbols can resolve that member's own GOT,
but are not published as library definitions. This is symbol visibility metadata, not function
glue or another Rust interface.
Pointers and lengths below are wasm32 u32 values. The game provides these imports in the
horizon module:
host(id, in_ptr, in_len, out_ptr, out_cap) -> u32invokes a generated import ID. Arguments are Wire values in order. It writes the reply if it fits and returns its length; if the length exceedsout_cap, callhost_reply(out_ptr)after allocating that many bytes to copy the retained reply. Host errors trap the call.host_reply(out_ptr)copies that retained reply.
Each guest exports:
horizon_layout() -> u64: the generated layout hash. A mismatch is refused.horizon_layout_ext() -> u64, whenmod_pointsis linked: the full point catalog's layout hash. The loader checks it before accepting any listener.horizon_in(len) -> ptr: the buffer into which the game writes the next call's input.horizon_hook(id) -> u64: runs the generated export ID after the input is written. The high 32 bits are the reply pointer and the low 32 bits its length;u32::MAXas the length denotes an error, with a Wire string at the pointer. The reply remains valid until the next call at the same depth. A handler call starts its input with the handler'su64ID.
The reserved IDs are INIT = 0xFFFF_FF00 (guest package-list initialization),
RELEASE = 0xFFFF_FF01 (guest handler IDs to release), and HOST_RELEASE = 0xFFFF_FF02
(host object ID to release), and HOST_LOG = 0xFFFF_FF03 (a guest panic string, with no reply).
Other IDs index the generated import or export table. A hook reply
contains its return value followed by writable arguments in field order.
Points use that same hook export, call arena and execution budget. Place IDs are
0x8000_0000 + place. The subscription query 0xBFFF_FFFF returns (place, injection_id)
pairs. Point call input starts with the u32 subscription index. A HostSide point then carries
a server/client boolean, followed by its listener record. Calls reply with unit and the record,
which the host decodes completely before writing any field back.
Wire values use little-endian numbers; lengths and collection counts are u32, strings are a
byte length followed by UTF-8 bytes, options have a byte tag (0 or 1), enums have a u32
case followed by its fields, and records contain their fields in declaration order. Readers reject
invalid tags, invalid UTF-8, trailing data, lengths that cannot fit in the remaining bytes, and
nesting beyond the shared Wire budget of 1040 protocol levels (which accommodates vanilla's
512 nested NBT lists or compounds). Interned static strings are limited to 256 bytes and 4096 distinct
strings per process. Execution, memory and table limits are enforced separately by the loader.
In the game
Each dependency group uses shared memory and one copy of the standard library; its members are core wasm shared modules, not components. A layout mismatch or an unresolved dependency is refused and named on the Mods page.
The game limits execution time, memory and table usage. A trap or limit hit disables the affected
group. APIs marked Not on wasm in their Rust documentation cannot be used by wasm mods.
Compiled module cache
The game caches compiled wasm modules in <game_dir>/.horizon-cache/wasm/, keyed by the
wasm bytes, Wasmtime compatibility and engine settings. Hits map the compiled file into memory;
files shipped beside a mod are never loaded as compiled code.
On Unix, the game creates the cache folder with mode 0700 and entries with mode 0600. It checks folder ownership and permissions, then checks each opened entry is a regular file owned by the current user, not writable by group or others, and has only one hard link. Symlink entries are refused. On Windows, persistent caching is disabled until owner, DACL and inheritance trust can be verified for the cache folder and opened entries. Modules compile from wasm instead, with one warning explaining the fallback. The player's account is the trust boundary.
A failed check logs the reason once per launch. Unavailable, corrupt or incompatible entries fall back to compiling the wasm. Writes replace entries atomically without changing files that running modules have mapped. Deleting the cache is always safe: the next launch rebuilds it.
Cache reload-stable recipe definitions
A machine should not fetch and parse its recipe files on every block-entity tick. Cache the definitions, not the answer to whether a recipe matches. This pattern uses the same source and API for native and wasm mods. It needs no wasm-specific memoizer or polling import.
Publish after the final server data
Listen to tags_updated on the server side. It runs after the recipes and tags have
been published, both on first open and after a successful reload. Build the whole next
table before taking the write lock, then replace the old table in one operation.
An empty table must replace the old one too: pack overrides and removals matter.
Tag-only reloads also rebuild the ingredients. A failed engine reload does not fire
the publication hook, so the previous definitions remain in force.
A reload listener's prepare/apply phase is useful for reading your own resources, but is too early to cache the final recipe table. Do not move this work into the first tick as a lazy initialization path.
This example is a custom recipe type, whose fields live in Recipe::json. Use
the host's existing ingredient and result parsers rather than implementing tag,
component or custom-ingredient semantics yourself. Invalid definitions are skipped
in file order; the first matching valid definition wins.
/// Definitions owned by the one authoritative server.
#[derive(Default)]
pub struct Processing {
recipes: RwLock<Vec<Definition>>,
}
struct Definition {
ingredient: Ingredient,
result: ItemStack,
}
impl Processing {
fn clear(&self) {
self.recipes.write().unwrap().clear();
}
fn publish(&self, host: &dyn Host) {
let next = host
.recipes_of_type("example:processing")
.iter()
.filter_map(|recipe| {
Some(Definition {
ingredient: host
.recipe_ingredient(&recipe.json["ingredient"].to_string())
.ok()??,
result: host
.recipe_result(&recipe.json["result"].to_string())
.ok()??,
})
})
.collect();
*self.recipes.write().unwrap() = next;
}
/// Select against the current input and current host, never a remembered match.
pub fn result(&self, host: &dyn Host, input: &ItemStack) -> Option<ItemStack> {
let recipes = self.recipes.read().unwrap();
recipes
.iter()
.find(|recipe| recipe.ingredient.test(host, input))
.map(|recipe| recipe.result.clone())
}
}
Clear the table at server startup and after teardown. Share the listener's cache
with the machine Part (for example using Arc<Processing>); do not create a new
cache in the tick. Until publication, the empty table means no recipe can run.
#[mod_api::horizon_mod]
impl Hooks for Processing {
fn server_about_to_start(&self, _: &mut dyn ServerHost, _: &mut ServerAboutToStartEvent) {
self.clear();
}
fn tags_updated(&self, host: HostSide<'_>, _: &mut TagsUpdatedEvent) {
if let HostSide::Server(host) = host {
self.publish(host);
}
}
fn server_stopped(&self, _: &mut dyn ServerHost, _: &mut ServerStoppedEvent) {
self.clear();
}
}
This ownership assumes one authoritative server per process and mod group, as in the current integrated, LAN and dedicated entry points. The connected client does not own this server table. If the engine ever supports concurrent servers in one group, key the cache by a server identity once the API offers one; neither a recipe-type key nor a dimension ID distinguishes servers. Startup/teardown clearing prevents reuse across sequential world opens, not concurrent ones.
Keep live answers live
Every tick, call result with the actual input. Ingredient::test still consults
the host and custom matchers each time. Do not remember a match by item ID: the
stack's components or the matcher's world-dependent answer can change without a
reload. This example takes one read guard per selection, not one per recipe, and
clones only the selected result stack, not the recipe array or its JSON.
After selection, check the current output's compatibility, count and stack limit. Keep capability, pipe, inventory and world reads live too. Caching definitions does not authorize a write or reserve output capacity. A blocked machine resets progress according to its existing policy.
The machine sample demonstrates the full processing and transfer path. Test reload overrides, empty/removal and tag-only reloads, sequential worlds, replaced input (components included), and a custom matcher whose live answer changes. A test should fail when publication is mutated to keep the old table. Measure end-to-end tick time separately from reload cost; fewer imports alone do not prove a particular millisecond saving.
Check biome tags
Use the world's live biome tags without loading chunks:
use mod_api::{Host, LevelRef};
fn is_forest(host: &dyn Host, level: LevelRef, biome: &str) -> Option<bool> {
host.level_biome_in_tag(level, biome, "minecraft:is_forest")
}
The tag may also start with #. An undefined tag returns Some(false) for a known biome. An unknown biome or unavailable level returns None; do not confuse that with a known biome outside the tag. Data-pack biome tags participate in this query.
Controlling spawns
Four hooks stand in for the checks and setup around a mob. All run on the server, get the read-only host, and fire on replayed ticks, so a listener must be a pure function of its inputs. finalize_spawn can also change the mob it is about to finalize, through the editor it is handed, and the pack's group data; it changes nothing else.
| Moment | Hook | Answer |
|---|---|---|
| The spawn predicate for a type and position | mob_spawn_placement_check | SpawnCheck::{Default, Succeed, Fail} |
| The mob's own position and obstruction checks | mob_spawn_position_check | SpawnCheck::{Default, Succeed, Fail} |
Just before finalizeSpawn | finalize_spawn | Flow::Cancel, spawn_cancelled, a new difficulty, edits to mob, a new group |
Each tick, before a mob's checkDespawn | mob_despawn | Despawn::{Default, Allow, Deny} |
Saying where a mod mob spawns
Registrar::declare_spawn_placement(path, rule) is NeoForge's RegisterSpawnPlacementsEvent: during registration a mod gives its entity type a SpawnPlacementRule, and the game's own spawn checks answer from it for that type, wherever they run (natural spawning, chunk generation, spawners, patrols, sieges, reinforcements).
r.declare_spawn_placement("ghoul", mod_api::SpawnPlacementRule {
placement: mod_api::SpawnPlacementType::OnGround,
heightmap: mod_api::Heightmap::MotionBlockingNoLeaves,
spawnable_on: Some("mymod:ghoul_ground".to_owned()), // a block tag the block below is in
light: mod_api::SpawnLight::Dark, // Any, Dark (a monster's) or Bright (an animal's)
biome_tag: Some("mymod:haunted".to_owned()), // a biome tag the spot's biome is in
}).unwrap();
The placement type is SpawnPlacements.isSpawnPositionOk's first check, and the rule stands in for the type's spawn predicate: every field that is set must hold, and a trial spawner ignores light. A type with no declaration keeps its template's rules. The game resolves the declarations once at load, so a check reads one table and never walks the mods. A tag is a namespaced name written without #; an id outside the mod's namespace, a bad tag or a second declaration of the same type refuses the mods at startup, and a type no content file defines refuses the resource load.
mob_spawn_placement_check is the separate per-check moment: it runs after the rule and is told its answer as default_result, so a listener can still overturn it for one position.
Walking every entity in a level
host.level_all_entities(level) returns the authoritative level's available
EntityRefs without an AABB filter, including spectator players. Unlike
level_entities_in, it does not append the dragon's parts: vanilla's
ServerLevel.getAllEntities iterates the entity lookup, not multipart hit boxes.
A remote client, stale handle or parked level returns None, never a partial
client-side list. The query also returns None while any lookup member is
temporarily extracted, rather than returning an incomplete authoritative list.
The walk preserves lookup admission order across storage families, including
removal and re-admission at the end. A chunk's entities join in the order the
chunk was stored: vanilla's storeChunkSections order, section by section from
section 0 upward and then the negative sections, each section in admission
order, whatever storage family holds each entity. A vehicle comes before its
passengers. Three limitations remain: an entity that moved into another section
since it joined is stored at its admission place there, where vanilla moves it
to the end of the section it entered; entities with a zero runtime ID fall back
to stable storage join order, not runtime admission order; and unregistered
entity kinds have no authoritative lookup handle and are omitted from this query.
This is not complete saved-world or whole-level ordering parity (#2703).
The call allocates buffers for the result and sorts admission keys; it does not
allocate an object per entity. Cache the result when doing several passes in one
hook rather than repeatedly crossing the host boundary.
Checking a spawn
mob_spawn_placement_check and mob_spawn_position_check both get the level the mob would join (a LevelRef, so a
listener can read the blocks around pos through the host) and the spawner that asked: Spawner::Block { pos } for a
spawner block, Spawner::Trial { pos } for a trial spawner, and None for natural spawning, chunk generation, patrols,
sieges, reinforcements and a spawner minecart. Set *event.result to SpawnCheck::Succeed or Fail to force the
check; Default keeps vanilla's answer.
fn mob_spawn_placement_check(&self, _: &dyn mod_api::Host, event: &mut mod_api::MobSpawnPlacementCheckEvent<'_>) {
// spawner blocks work as usual; nothing else spawns zombies here
if event.kind == "minecraft:zombie" && event.spawner.is_none() {
*event.result = mod_api::SpawnCheck::Fail;
}
}
The position check also gets event.mob, the mob about to spawn, as an UnspawnedMobRef with the same reads as
finalize_spawn's (no editor: the hook only reads). Vanilla creates the mob before this check; Horizon creates it there
only when the hook has a listener, the same way the spawn would, and the spawn then goes on with that very mob, so what
the listener read is what joins the level. A refused check drops the mob: it never joins the level. Creating it draws
nothing from the level's random, so a listener that answers Default changes no draw.
fn mob_spawn_position_check(&self, host: &dyn mod_api::Host, event: &mut mod_api::MobSpawnPositionCheckEvent<'_>) {
if event.mob.custom_name(host).is_ok_and(|name| name.is_some()) {
*event.result = mod_api::SpawnCheck::Succeed;
}
}
Where the game does not create the mob fresh, the handle answers only kind and position (the rest is
UnspawnedMobError::Unsupported): chunk generation's packs, which the world generator creates and finalizes after the
check, and a spawner or trial spawner whose spawn data carries entity data, which the game loads after the tick.
Stopping a spawn or leaving a mob plain
finalize_spawn has two cancels. Returning Flow::Cancel skips finalization: the mob still spawns, but with no equipment, attributes or class setup. Setting event.spawn_cancelled = true adds no mob at all.
fn finalize_spawn(&self, _: &dyn mod_api::Host, event: &mut mod_api::FinalizeSpawnEvent<'_>) -> mod_api::Flow {
if event.kind == "minecraft:zombie" && event.reason == mod_api::SpawnReason::Natural {
event.spawn_cancelled = true;
}
mod_api::Flow::Continue
}
finalize_spawn fires only where vanilla calls finalizeSpawn, so a breeding, an infested block's silverfish or a thrown egg's chick does not tell it.
For /summon minecraft:ender_dragon without NBT, command placement starts at zero
rotation and consumes no random yaw draw. Skipping finalization also skips its
follow-range and handedness draws. Spawn eggs retain their random placement yaw;
an explicit NBT compound follows the load route without finalization.
Changing the mob before it is finalized
event.mob is the mob itself, an UnspawnedMobRef: it is in no level yet, so it is not an EntityRef, and only this
hook call can use it. Read it through the host (kind, position, equipment, attribute_base, attribute_value,
tags, custom_name) and change it through event.edits, which reaches only this mob: equipment (an API-owned ItemStack), an attribute's base or a permanent modifier, tags and the custom name. An edit applies at once, so the
reads and the next listener see it. Once the hook returns, the handle answers UnspawnedMobError::Expired, as does a
handle from another call passed to this call's editor.
fn finalize_spawn(&self, host: &dyn mod_api::Host, event: &mut mod_api::FinalizeSpawnEvent<'_>) -> mod_api::Flow {
if event.mob.kind(host).is_ok_and(|kind| kind.path() == "zombie") {
let health = mod_api::Identifier::parse("minecraft:max_health").unwrap();
let _ = event.edits.set_attribute_base(event.mob, &health, 40.0);
let _ = event.edits.add_tag(event.mob, "mymod:tough");
}
mod_api::Flow::Continue
}
Listeners run before finalization. Finalization's own rolls still follow them: equipment it rolls for a
slot replaces what a listener put there, and the attribute bonuses it adds apply on top of a listener's base. Return
Flow::Cancel to keep exactly what the listener left, since no finalization runs. Attribute edits reach the
attributes a mob keeps itself (max health, movement speed, follow range, attack damage, knockback resistance,
spawn reinforcements, jump strength, burning time, flying speed); the others answer Unsupported until the mob has
joined, when a LivingRef writes them. The ender dragon's handle answers its kind and position, and Unsupported
for the rest. A mob finalization itself creates (a chicken jockey's chicken, a zombie horse's rider) fires the hook
again with a handle of its own.
The hook still fires on replayed ticks with the read-only host, so the editor and group are the only things a
listener changes, and it must change them the same way every time: a pure function of the event.
event.group is the pack's SpawnGroupData as plain data (SpawnGroup, variants named as vanilla saves them):
read it, change it, set it or clear it, and finalization reads what is left. Every site the hook fires at hands it
None today: the game does not yet thread group data from one member of a natural spawning pack to the next, and
chunk generation's animals share it only inside the generator, out of the hook's reach. Setting a group still reaches this
mob's finalization; carrying it on to the pack's next member comes with that threading.
See the hook notes for what each spawn site covers and what is left out (breeding, loads, worldgen structure mobs).
Changing how large a spawn group grows
A natural spawn adds mobs of one type to a group until the group reaches the type's limit (Mob.getMaxSpawnClusterSize: 4 by default, more for horses, wolves and schooling fish, 1 for ghasts and pillagers). The entity part of the type changes that limit with EntityPart::spawn_cluster_size, which gets the read-only host, the mob just spawned, why and from what it spawned, and the limit vanilla would use. Return Some(limit) to replace it, or None to leave it to the next part and then to vanilla. The first part to answer wins, and the group stops growing once its size reaches the limit.
struct ZombiePacks;
#[mod_api::horizon_part]
impl mod_api::EntityPart for ZombiePacks {
fn spawn_cluster_size(
&self,
_: &dyn mod_api::Host,
_: mod_api::UnspawnedMobRef,
_: mod_api::SpawnReason,
_: Option<mod_api::SpawnSource>,
vanilla: u32,
) -> Option<u32> {
Some(vanilla * 2)
}
}
Only natural spawning's packs ask it: vanilla has no group limit anywhere else. A type with no part keeps vanilla's limit and costs nothing. The mob is the same UnspawnedMobRef as finalize_spawn's, finalized and not yet in a level, and the call runs on replayed ticks like finalize_spawn, so an answer must be a pure function of its inputs. Attach the part with r.entity_part("minecraft:zombie", ZombiePacks) as entity parts describe.
Keeping or removing a mob
mob_despawn fires once per tick for every mob in a loaded chunk, just before vanilla's Mob.checkDespawn, persistent mobs and mobs in peaceful mode included. Allow discards the mob, Deny keeps it, Default leaves vanilla's rules to decide. An answer other than Default skips all of vanilla's checks, so a Deny keeps a hostile mob in peaceful mode and neither answer takes the idle-despawn nextInt(800) draw.
fn mob_despawn(&self, _: &dyn mod_api::Host, event: &mut mod_api::MobDespawnEvent<'_>) {
if event.kind == "minecraft:zombie" {
*event.result = mod_api::Despawn::Deny;
}
}
The wither and the ender dragon override checkDespawn in vanilla, so the event is not fired for them.
Adding or removing a custom spawner
The level's custom spawners (phantoms, patrols, cats, village sieges, the wandering trader) are a list a mod edits once, in modify_custom_spawners. A mod's own spawner is a key plus custom_spawner_tick, which runs every tick while mob spawning is on:
fn modify_custom_spawners(&self, _: &dyn mod_api::Host, event: &mut mod_api::ModifyCustomSpawnersEvent<'_>) {
// no phantoms in this world
event.spawners.remove(&mod_api::SpawnerEntry::Builtin(mod_api::SpawnerKind::Phantom));
event.spawners.add(mod_api::SpawnerKey::new("mymod:rat_swarm").unwrap());
}
fn custom_spawner_tick(&self, host: &mut dyn mod_api::ServerHost, event: &mut mod_api::CustomSpawnerTickEvent<'_>) {
if event.key.as_str() == "mymod:rat_swarm" && event.spawn_enemies {
// spawn through `host`
}
}
Every mod is asked about every key, so a mod ignores keys it does not own. See the hook notes.
Spawning from a level
LevelRef::spawn_mob(host, kind, pos, reason) is EntityType.spawn: the mob is made as any of its type is, Parts, attachments and entity_constructing included, then finalize_spawn and finalizeSpawn run with reason, and it joins the level (entity_join_level), not persistent. It returns the mob's handle, or None with nothing spawned: a kind that isn't a mob, a position that isn't finite, outside Level.isInSpawnableBounds or in an unloaded chunk, a level not in place, a client, or a mod that cancelled the spawn or kept the mob out. The level's spawn limits, flags and spawn rules don't apply, as they don't to /summon. Items, experience and block drops have their own calls (spawn_item, award_experience, pop_resource), and any other entity summon.
A level's natural spawning (NaturalSpawner.spawnForChunk) also has a policy a mod can change:
spawn_limits(host)andset_spawn_limits(host, limits): aSpawnLimitswith each spawning category'sgetMaxInstancesPerChunk,SpawnLimits::VANILLA(70 monsters, 10 creatures, 15 ambient, 5 axolotls, underground water and water creatures each, 20 water ambient) until a mod sets others. A limit caps the category twice, as vanilla's does: over the level, underlimit * spawnable chunks / 289, and around each player near the chunk, underlimit. A limit of 0 stops the category.spawn_flags(host)andset_spawn_flags(host, flags): aSpawnFlagswithmonstersandfriendlies, both on until a mod turns one off. Off keeps that side out of the level's natural spawning; neither turns monsters on against thespawn_monstersrule or anything on withspawn_mobsoff.can_spawn_entities_in_chunk(host, [x, z]):ServerLevel.canSpawnEntitiesInChunk, the question natural spawning asks of a spawn position outside the chunk it started in. Horizon's spawner answers it as "the chunk is loaded", and so does this call.
fn level_load(&self, host: &mut dyn mod_api::ServerHost, event: &mut mod_api::LevelLoadEvent<'_>) {
let Some(level) = host.level(event.dimension) else { return };
// twice the monsters, no bats
level.set_spawn_limits(host, mod_api::SpawnLimits { monster: 140, ambient: 0, ..mod_api::SpawnLimits::VANILLA });
}
Only natural spawning reads the policy: spawners, trial spawners, patrols, sieges, phantoms, a new chunk's creatures and custom spawners keep their own rules. A change counts from the level's next spawn pass, and mobs already there stay. The policy is not saved: it holds until the world closes, while the level is parked and across its closing and opening again, and the next open starts from vanilla's, so set it in level_load. Reads work on any host and answer None on a client or for a stale handle; the writes need a ServerHost and return false there.
Declaring a machine's menu data
A mod block entity has two different kinds of storage: external item access uses
transactional Parts, while internal slots use the handle methods below; integer menu data is read with
BlockEntityRef::data_slots and changed on a server Host. Menu data is transient:
creation and loading both start with an empty data vector, even when the saved
payload still contains the machine's progress.
State-aware creation
Declare the block entity type and its owning blocks during registration. To make
creation depend on block properties, answer stateful_block_entity with true for
that block. The existing block_entity hook then receives the actual placed
state, not just the block identity. Read its properties through the Host's state
registry and supply the initial saved compound. The registered declaration owns
the entity type; an unanswered state-aware creation makes no entity.
Creation runs inside the block write, after placement side effects, on servers and clients. Changing properties within the same block preserves an existing entity even when a fresh instance of the new state would have no entity.
Size the data vector
Call entity.set_data_slots(host, initial_values) with the complete initial
vector when preparing a newly created or loaded machine. For a progress display,
use one element initialized from the progress value in its saved payload. This is
the live-handle replacement for assigning a mutable tick payload's data field.
Do not reset the vector to its initial values on every tick.
The method accepts at most the game's mod-menu data-slot limit (currently 256).
It returns Ok(true) when it changes the vector, Ok(false) for identical values,
or BlockEntityDataError::TooManySlots with the submitted count and limit. It
never silently clamps a vector. BlockEntityDataError::Unavailable means the
handle cannot reach a writable mod holder in the server level currently in place.
Update individual values
After sizing, call entity.set_data_slot(host, index, value) for scalar updates.
This method does not resize: an absent index returns Ok(false). Both methods use the
same write target, journal and setChanged path. Nested transactions retain their
ordinary behavior; an aborted outer transaction restores the vector that existed
before its first write, including writes committed by children. The normal menu
broadcast delivers committed changes to viewers.
The public handle resolves one entity's lifecycle generation as well as its level and position. Removal, replacement and chunk reload make it stale; in-place data edits preserve it. It is not a borrowed-payload API.
Internal items versus automation
For a configured mod container, item_slot(host, slot) snapshots an internal slot;
set_item_slot(host, tx, slot, stack) replaces it within an existing server
transaction. Any held mod block-entity handle can be used; there is no caller
ownership check. This deliberately bypasses inputs, outputs and face policy, so a
recipe can consume its input and produce its output atomically, including output
slots that automation cannot insert into. Use the ITEMS capability for external
insertion; its policy is unchanged. Vanilla containers are unavailable here.
Indices use the current container's declared count. Lazy slots read empty; saved
excess slots are neither exposed nor truncated. None clears, identical values
return Ok(false), and real changes send Changed at root commit. Nested abort
restores ordinary before-images, including committed-child writes. Invalid
indices, unknown/air/zero-count stacks and counts above the registered or
component-overridden maximum return BlockEntityItemError, never a partial write
or silent count clamp. A stale handle refuses a replacement rather than accessing
its container; acquire a new handle for the replacement.
One-shot client effects
To tell clients about something that happened once, such as a burst of particles, queue
fields instead of writing them to the saved nbt. In block_entity_tick, or another
event with a mutable entity, native and wasm mods alike call
event.entity.set_client_update_fields(fields), then host.block_entity_sync(event.pos).
A later call replaces the batch; an empty compound clears it.
The next block change that reaches a viewer puts the fields over what
block_entity_update_tag gives, or sends them alone without one, in one packet for every
current viewer, and the batch is gone. A change nobody sees leaves it waiting, and neither a
chunk sent to a player nor a resend of the blocks under a single player takes it. Removing
or unloading the block entity drops it. It is never saved, cloned or compared, and it does
not change the entity's revision.
A client merges the fields into its copy's nbt as any update field, and they stay there
until sent again: put a counter in them to tell one effect from the next.
Retaining a handle
A BlockEntityRef names one live entity, not every entity later placed at its position. Removal,
replacement and chunk reload invalidate it; reacquire with host.block_entity(level, position).
In-place data edits and temporary tick storage preserve identity. Option reads return None for
stale handles; writes return their error's Stale variant, never writing to a replacement.
set_lock, set_name, merge_data, replace_data, set_changed and set_data_slot
return Result<bool, BlockEntityError>: handle Err(BlockEntityError::Stale) by dropping
or reacquiring the handle. Ok(false) means unchanged data or an unsupported operation,
not a stale identity. Existing item and data-vector methods retain their specialized errors.
player.open_menu(host, entity) also returns Result<bool, BlockEntityError>:
Ok(true) means opened, Ok(false) means a non-provider, cancelled open or
unconnected/wrong-level player, and Err(BlockEntityError::Stale) means the entity
no longer resolves. It never opens a replacement through an old handle.
Drawing resolved models
Register standalone models during standalone_models, then resolve their ModelRef
through RenderAssets::model after resources are published. World render hooks receive
WorldDrawContext: submit transient copies with draw_model_instances, or retain
copies in a group. GUI scenes, mobs and kept/spinning renderers use their existing
CPU routes; groups are a world-drawing facility, not another renderer.
Terrain materials in a block entity
draw.model(model, tint) draws a resolved model as an entity model. A block entity, mob or
player submit hook that wants the terrain's own materials calls
draw.terrain_model(model, options) instead. That covers a machine's belts, shafts and
casings, cut out and shaded as blocks are:
let belt = TerrainUvShift::new("mymod:block/belt", "mymod:block/belt_scroll", [1.0, 0.5], [0.0, scroll]);
let options = TerrainModelOptions::default()
.with_material(TerrainMaterial::Cutout)
.with_uv_shift(belt);
draw.terrain_model(self.belt, options)?;
- Faces sample the animated block sprites, cull back faces and write colour and depth like opaque terrain. Light is the draw's own, raised by each quad's emission.
material:Modelkeeps each quad'srender_type(solid,cutout,cutout_mipped), and a translucent quad refuses the draw.Solid,Cutout(alpha under 0.1 discarded, no mipmaps) andCutoutMipped(under 0.5, mipmapped) choose one material for all quads.shade:Modelfollows each quad'sshadeflag,Offturns directional shading off, andEntityshades every quad by the level's entity light directions. Full-bright light never turns shading off.uv_shiftdraws faces of exactly one sprite with another, their UVs scaled then moved.uv_shrink_ratio(0.0..=1.0, default0.0) pulls UVs towards each face's centre first, for a model baked with an olderFaceBakery's atlas shrink. The engine never guesses it.tint(0xRRGGBB) multiplies only quads with a tint index.- A stale handle is
TerrainModelError::Model. If any quad can't be drawn, the result isTerrainModelError::Refusedand nothing is drawn. That covers a translucent quad underModel, a missing sprite, a pose that flattens the model, a ratio out of range, and a shift whose numbers aren't finite, whose sprites are unknown or whose source sprite no face uses.
Persistent groups
host.create_group(model, capacity)reserves up to 16,384 copies of a solid model. Initial drawn length is zero. Translucent models are refused.group.write(host, first, copies)atomically overwrites a range and extends drawn length if needed. Unwritten slots start as white, full-bright identity copies at world origin. Invalid coordinates or light, or a range beyond capacity, change nothing.group.set_len(host, len)changes drawn length without changing reserved capacity. Shrinking to zero draws nothing; growing restores retained copies, including hidden slots written earlier.group.set_group_transform(host, transform)sets an identity-by-default row-major 3×4 affine pose. For each local vertex the composition is exactlyworld = origin + group_transform * (copy_transform * local). The world-space f64 origin is not rotated or scaled. Non-finite pose values change nothing.draw.draw_instance_group(group)draws the live length once per view. Repeated submissions in that view are ignored. Culling uses the composed pose.group.destroy(host)retires the handle and releases capacity and CPU caches. Copies already accepted for a view remain drawn; subsequent calls returnStale.
Each mod may reserve 65,536 copies across all its groups; a zero-capacity group reserves one slot. Destroying a group releases that reservation immediately. A view accepts 16,384 visible copies shared across transient batches, groups and listeners, and at most 16,384 group submissions. Culled copies do not consume the visible-copy budget. Refusal is atomic: no prefix of a refused submission draws.
Groups cache converted records on the CPU. Unchanged groups avoid pose, normal and tint conversion, but still cull and stage visible copies each frame. Every visible GPU-routed copy uploads each frame; groups do not retain a private GPU buffer. Frequent whole-group pose changes rebuild every live record and can cost more than uncached transient copies. Shader-pack selection, missing prepared GPU records and overlays use the same CPU baking path as transient batches, including tint and light. There is no simulation-tick work.
Only the creating mod can access a group. A foreign handle returns GroupError::Stale,
as does a destroyed group, a group after resource reload or world close, or a group
whose mod was disabled. Re-enabling starts without groups: recreate them from the
newly published model handles. Slot reuse never revives an old handle. Native and
wasm use the same ownership, budgets, lifetime and rendering implementation.
Still fluids in a block entity
A block entity, mob or player submit hook draws a tank's contents with
draw.fluid_box(&stack, bounds, options), where bounds is
[x0, y0, z0, x1, y1, z1] in blocks under the draw's current pose:
let options = FluidBoxOptions::new(false, 4.0 / 1024.0);
draw.fluid_box(&tank.stack, [0.125, 0.0625, 0.125, 0.875, level, 0.875], options)?;
- The stack is
minecraft:waterorminecraft:lavawith no components. Any other fluid, an unresolved one or a component patch isFluidBoxError::UnsupportedFluid. - Each face tiles the fluid's animated still sprite once per block, cut at whole-block lines.
Each tile keeps its place in the sprite. The box is not inset.
uv_shrink_ratiois the sprite'sTextureAtlasSprite.uvShrinkRatio(0.0..=1.0), andrender_bottomadds the bottom face. - The colour is the stack's tint (plain water
0xFF3F76E4, lava white), never the biome's water colour.with_argbreplaces it, alpha included. Faces have no directional shading. Sky light is the draw's own, and block light rises to the fluid's (lava 15). - The faces use a material of their own: sorted far to near, alpha blended, culled from behind, and depth-writing. They draw with the block entities, before the level's translucent terrain, under shader packs too.
- A stack of 0 draws nothing and returns
Okwithout checkingbounds. Non-finite or reversed bounds, a box of more than 65,536 tiled quads and a ratio out of range areFluidBoxError::InvalidBox. A refusal draws nothing.
A player's or humanoid's parts
mob_layer gets a humanoid's parts as vanilla posed them this frame (parts,
HumanoidModel.setupAnim), read-only, to attach geometry to an arm or the head without
animating it again. Only HumanoidMobRenderer's families have them (players, zombies and their
kin, skeletons, piglins); every other mob gets None, a cow included, though its model has a
head and a body. PosedParts is a fixed-size snapshot: get(part) or iter(). It is taken
from the vanilla body's own draw, so a part player_submit hid (below) has visible false.
player_submit can hide single parts of the vanilla player model instead of replacing all of
it: hidden.hide(HumanoidPart::Head), as a mod drawing its own outer skin layer does.
- Hiding happens after Skin Customization, for this frame only.
- A hidden part takes its children with it: the hat goes with the head, a sleeve with its arm.
- Only the body loses the part. The armour, held items and other layers keep every part, and the cape, a layer of its own, stays.
Drawing with your own render pipeline
A client mod can draw world quads with its own fragment shader. The mod describes the pipeline as
data, the game builds it, and the mod draws by handle. This is the counterpart of NeoForge 26.3's
RegisterRenderPipelinesEvent, in a restricted form: the mod supplies the fragment stage and
the host fixes the rest. Vanilla requires a vertex shader and a primitive type; this API fixes
both rather than exposing the full pipeline description.
1. Write the shader
Put a WGSL file at assets/<namespace>/shaders/pipeline/<path>.wgsl. It defines one function:
fn pipeline_fragment(input: PipelineInput) -> vec4<f32> {
let pulse = 1.0 + sin(frame.time * 4.0);
return vec4(input.color.rgb * pulse, input.color.a * (1.0 - input.uv.y));
}
The game puts this before your file:
| Name | Type | Meaning |
|---|---|---|
input.uv | vec2<f32> | The quad's texture coordinate at this fragment, as its corners gave it. |
input.color | vec4<f32> | The quad's colour in linear light; alpha is coverage. |
input.position | vec3<f32> | This point in blocks, relative to the camera. |
frame.time | f32 | Seconds of game time, smooth between ticks, wrapping to 0 every hour. |
frame.viewport | vec2<f32> | The world picture's size in pixels. |
Colours follow the mod render colour rule: the function reads
and returns linear light, 1.0 is paper white, and more is brighter where the frame can hold it. The
alpha you return is coverage. The game supplies the vertex stage, the bindings and the conversion
to the frame, so a file that declares a binding or an entry point is refused. Names that start with
horizon_ belong to the game.
2. Register the pipeline
fn register_render_pipelines(
&self,
_context: &RegistrationContext,
event: &mut RegisterRenderPipelinesEvent<'_>,
) {
event.pipelines.push(RenderPipeline::new(
Identifier::parse("example:glow").unwrap(), // the pipeline's id
Identifier::parse("example:glow").unwrap(), // assets/example/shaders/pipeline/glow.wgsl
PipelineBlend::Additive,
WorldDepth::Tested,
));
}
PipelineBlend::Translucent lays the result over the frame by its alpha, and Additive adds it,
scaled by its alpha. WorldDepth::Tested hides the quads behind nearer world surfaces, and
ThroughWalls draws them over everything. No pipeline writes depth.
The id must be in a namespace your mod owns. There is no per-mod pipeline count cap. A registration that breaks either rule, or reuses an id, is logged with the mod's id and dropped.
3. Resolve and draw
fn client_setup(&self, host: &dyn ClientHost, _event: &mut ClientSetupEvent<'_>) {
let id = Identifier::parse("example:glow").unwrap();
*self.glow.lock().unwrap() = host.render_assets().render_pipeline(&id).ok();
}
fn submit_custom_geometry(&self, _host: &dyn ClientHost, event: &mut SubmitCustomGeometryEvent<'_>) {
let Some(glow) = *self.glow.lock().unwrap() else { return };
let quad = PipelineQuad::spanning(corners, 0xFFFF_C040);
if let Err(error) = event.draw.pipeline_quads(glow, &[quad]) {
log::warn!("glow not drawn: {error:?}");
}
}
Resolve again in client_resources_reloaded: every resource reload builds the pipelines again
from the new resources, so a resource pack can replace your shader, and a handle from before the
reload answers Stale.
| Error | When |
|---|---|
RenderPipelineError::Unknown | No mod registered that id. |
RenderPipelineError::Invalid | The shader file is missing or was refused. The log has a line render pipeline <id> refused: <file>: <reason>, with the file's line number for a syntax or type error. |
RenderPipelineError::Stale | The handle is from before a resource reload. |
PipelineDrawError::NonFinite | A corner or texture coordinate is not finite. |
PipelineDrawError::CapacityExceeded | The view's 4,096 quads, shared with WorldDrawContext::quads, are used up. |
A refused batch draws nothing. A pipeline that is Invalid never stops the game from starting.
What a pipeline draws, and when
pipeline_quads works wherever a WorldDrawContext is given: submit_custom_geometry and each
render_level_stage. Within a stage, pipeline quads draw after that stage's plain quads and
lines, in the order submitted. Under a shader pack they follow the plain quads: a stage the pack
draws itself is drawn with the next stage the game reaches.
A pipeline cannot sample textures, take values of its own from the mod, or replace the vertex stage yet.
Emitting game events with context
Use LevelRef::game_event(host, event, at, context) to emit a vanilla game event
at an explicit position. For example, event can be "minecraft:block_change"
and at is an [f64; 3] position. This is a server operation; vibration listeners
such as sculk sensors and wardens hear the event through the normal game path.
GameEventContext mirrors vanilla's GameEvent.Context:
source_entity: Option<EntityRef>identifies the responsible entity. Its current spectator, sneaking, vibration-dampening and other listener facts are captured when the event is emitted.affected_state: Option<BlockState>identifies the state affected by the action, not necessarily the state currently at the position. Wool, for example, dampens the vibration when supplied as the affected state.
Use GameEventContext::default() when neither field applies. Both fields cross
the same Wire boundary on native and wasm. An unknown event, invalid position,
stale or foreign source, invalid state or inactive level returns false without
emitting an event. A source must belong to the level in place.
EntityRef::game_event(host, event) is the convenience operation for an event at
the entity's position with that entity as source and no affected state. It calls
the same level operation; use the level form when the position or state differs.
Maths and network NBT
mod_api::strict_math provides the established platform-independent StrictMath
operations: log, log10, cbrt, hypot, atan, asin, acos and atan2.
They are pure functions with identical bits on native and wasm. It does not expose
sin, cos, tan, exp, pow, or the AArch64 FMA variants.
mod_api::java_math exposes every engine jmath free function for native mods,
including round and round_f32. These are the paths used for vanilla Math
call sites; rounding can differ by machine, and universal HotSpot bit equality
is not promised. Calling them on wasm fails to compile and points to strict_math.
Tag::to_network_bytes() writes the engine's nameless network root, preserving
NaN bits and modified UTF-8 (including unpaired UTF-16 surrogates).
Reading follows the engine codec: negative floating zero normalizes to positive
zero, and mixed-list compound wrappers are unwrapped.
Tag::from_network_bytes(&bytes, None) returns (tag, consumed_bytes);
bytes following the root remain unread. Tag::End encodes as [0] and represents
vanilla's null marker. Errors have the exported mod_api::NbtError type.
The default read quota is mod_api::NETWORK_NBT_QUOTA (2,097,152 bytes), matching
vanilla 26.3 ByteBufCodecs.TAG and NbtAccounter.defaultQuota. It counts heap
charges, not wire bytes: primitive tag overhead, array contents, UTF-16 strings
and compound keys, list references, and distinct compound entries. Pass
Some(larger_quota) for trusted data. Both methods allow 512 container levels;
the writer refuses strings over 65,535 modified UTF-8 bytes. Vanilla applies
heap quotas on reads, so writing does not impose a heap quota.
The implementation follows vanilla 26.3 NbtIo.readAnyTag/writeAnyTag,
FriendlyByteBuf.readNbt/writeNbt, NbtAccounter, and each TagType.load.
It shares the engine reader and writer. These explicit codec calls cost time
proportional to the encoded data and add no tick-time work or Host calls.
Playing entity keyframe animations
A mod or resource pack can ship a NeoForge-format entity animation. Resolve it through the
client's RenderAssets::animation, bake its bone names against your ModelPart tree once,
and keep the result for playback. Native and wasm mods use the same types and sampler;
vanilla mobs use that sampler too.
Write the resource
Put this file at assets/demo/neoforge/animations/entity/arm/wave.json. Its animation ID is
demo:arm/wave:
{
"length": 1.0,
"loop": true,
"animations": [
{
"bone": "arm",
"target": "minecraft:rotation",
"keyframes": [
{"timestamp": 0.0, "target": [0, 0, 0], "interpolation": "minecraft:linear"},
{"timestamp": 0.5, "target": [90, 0, 0], "interpolation": "minecraft:linear"},
{"timestamp": 1.0, "target": [0, 0, 0], "interpolation": "minecraft:linear"}
]
}
]
}
length and timestamp are seconds. Keep keyframes in time order and use finite values.
loop defaults to false. The built-in targets use vanilla's builder conventions:
minecraft:position: texels, with the JSON y value negated when loaded.minecraft:rotation: degrees in the file, converted to radians when loaded.minecraft:scale: scale factors in the file;[1, 1, 1]adds no scale offset.
The built-in interpolations are minecraft:linear and minecraft:catmullrom. An identifier
without a namespace uses minecraft:. An unknown target or interpolation rejects that file
with a named diagnostic; other animation files still load. Lookup returns None when the ID
is absent or its file failed to load.
Resolve and bake after resources are published
Resolve during client_setup, and resolve again during client_resources_reloaded. These
are published-resource reads, not per-frame work. For example, renderer-owned state can keep
its model and baked animation together:
#![allow(unused)] fn main() { use mod_api::{ AnimationState, Identifier, KeyframeAnimation, ModelPart, RenderAssets, }; struct AnimatedModel { root: ModelPart, animation: Option<KeyframeAnimation>, state: AnimationState, } impl AnimatedModel { fn new(root: ModelPart) -> Self { Self { root, animation: None, state: AnimationState::new() } } fn reload(&mut self, assets: &dyn RenderAssets, id: &Identifier) { self.animation = assets.animation(id).map(|holder| holder.bake(&self.root)); } fn start(&mut self, tick_count: i32) { self.state.start(tick_count); } fn pose(&mut self, age_in_ticks: f32) { self.root.reset_all_poses(); if let Some(animation) = &self.animation { animation.apply(&mut self.root, &self.state, age_in_ticks); } } } }
Call reload from those lifecycle hooks with the published assets and ID, and start when
that entity begins the action. Keep a separate animation state for each animated entity.
A new state is stopped; start_if_stopped does not restart an already playing animation,
animate_when starts or stops with a condition, and stop ends playback.
Baking follows vanilla 26.3's ModelPart.createPartLookup: the name root always means the
actual root, even if a child is also named root. Other names select the first match,
visiting each part's immediate children before their descendants. A missing bone is logged
and dropped at bake time; the other channels still play. Re-bake if the model hierarchy
changes, not just when animation resources change.
Apply a pose each frame
The model's pose is mutable, and animation offsets add to it. Reset to the initial pose
before applying that frame's animations, as in pose above; otherwise offsets accumulate.
apply measures elapsed time from the state's start tick at 50 ms per tick.
apply_scaled changes playback speed. For a walk cycle use
apply_walk(root, walk_pos, walk_speed, speed_factor, scale_factor): its time is
walk_pos * 50 * speed_factor milliseconds, and its strength is
min(walk_speed * scale_factor, 1). apply_static applies the first frame without a state.
Looping animations wrap elapsed time by their length.
Do not look up, clone or bake the animation every frame: those steps copy or allocate data. Playback uses the retained child-index paths and channels without allocating or searching bone names. Pose application alone does not draw anything; submit your model through its renderer, alongside the resolved-model drawing facilities.
Publishing your mod
Publish your mod
Horizon's registry lists mods, modpacks, resource packs and shader packs made for Horizon. Players find them at playhorizon.dev/mods and in the launcher. Each file is checked before installation.
- Run
cargo horizon-mod checkon the file and put its source in a public repository under a suitable licence. - Open playhorizon.dev/mods/submit, sign in with GitHub, and submit the page and version file. The service opens a pull request for review.
- Submit each new version's file through the same form. A qualifying numeric-ID owner's version-only update can merge after checks; page changes wait for review. Published versions are pinned and never replaced: submit a new version instead.
Legacy entries already following a repository continue to use their existing release route; new entries cannot follow repositories. The full rules are in the registry index.
Once it is published, its page and its card in the launcher show its rating: players signed in with GitHub on its page give it 1 to 5 stars, one rating per account, which they can change. Its owners can't rate it, and nobody sees who rated.
Integrity and signing
- Integrity. Every download is checked against the SHA-256 the index lists before the file reaches the game folder.
- Signing. Authors sign files with an Ed25519 key (minisign format: a
.minisigbeside the file); the index pins each project's public key, so an update signed by another key is refused. Unsigned local files stay allowed, with an "unsigned" mark. - Takedowns. A file the registry takes down (
revoked.toml) is refused by the launcher and turned off where installed.
Query attachment holders
Use an attachment query when a system needs the loaded holders of one
AttachmentType<T>, rather than checking every entity, chunk or block entity.
Declare the key once, as described in server attachments.
Import AttachmentQueries for reads and AttachmentQueriesMut for server writes.
Both traits use the same API for either build target.
Read a snapshot
Call host.query(&TYPE) on &dyn Host, then borrow its rows with batch.iter().
Reads require T: Send + Sync + 'static, not Clone. Each successful row has a
Copy holder and a borrowed value: &T. Match AttachmentHolder to work with the
existing EntityRef, PlayerRef, BlockEntityRef, ChunkRef or LevelRef.
Copying that identity does not keep its holder loaded. Attachment ownership follows
an entity object, not its saved UUID. A rejected passenger can own attachments
without being in the level; it is absent from queries until admitted. Equal-UUID
objects never share attachment contents.
This example defines a serialized counter and sums its captured values:
use mod_api::*;
static VALUE: AttachmentType<u32> = AttachmentType {
namespace: "query_fixture",
name: "value",
default: || panic!("no defaults"),
codec: Some(AttachmentCodec {
encode: |value| Ok([("n".into(), Tag::Int(*value as i32))].into()),
decode: |saved| match saved.get(&"n".into()) {
Some(Tag::Int(n)) => Ok(*n as u32),
_ => Err(AttachmentError::Decode),
},
}),
copy_on_death: false,
sync: false,
};
fn total(host: &dyn Host) -> u32 {
host.query(&VALUE)
.unwrap()
.iter()
.map(|row| *row.unwrap().value)
.sum()
}
The example deliberately panics if a default is inserted: queries never call the
key's default, dirty the holder, or queue synchronization. It uses unwrap to
check test assumptions; real mods should handle key and row errors according to
their own policy. A saved entry decodes lazily when visited. A malformed saved
value or incompatible Rust type is a row error, not a silently skipped row or a
default replacing the saved data.
A batch is a snapshot of both membership and stored value bindings at query
creation. Later sets, removals, loads and unloads do not change that batch.
Create a fresh query to see them. Rows borrow from the batch, not from the host,
so the host can be used while a batch is retained. Rows cannot outlive their
batch. Interior mutability inside T is not deep-frozen by a snapshot.
Write explicitly on the server
Use host.query_mut(&TYPE) on &mut dyn ServerHost (T: Clone + Send + Sync + 'static). This does not return &mut T. A mutable query row exposes holder(),
value(), set(host, replacement) and remove(host).
The same example starts with 41, replaces it with 42, checks that a fresh query sees 42 while the original row still reads 41, then removes the live value:
let batch = host.query_mut(&VALUE).unwrap();
let row = batch.iter().next().unwrap().unwrap();
assert_eq!(*row.value(), 41);
row.set(host, 42).unwrap();
assert_eq!(total(host), 42);
assert_eq!(*row.value(), 41);
assert_eq!(row.remove(host).unwrap(), Some(42));
assert_eq!(total(host), 0);
Every set/remove validates the captured residency against the supplied host's
current dimension before using the ordinary attachment write path. Unload or
replacement refuses the write with AttachmentRowError::StaleHolder; it never
revives the holder. A missing live holder is also reported as stale. Removing the
attachment value alone does not invalidate the still-loaded holder: a retained
row can explicitly set a replacement value there.
Each write joins the caller's existing transaction. For an all-or-nothing system,
import HostTransaction and perform the loop inside host.transaction, as in
the HostTransaction reference.
There is no implicit
transaction and no commit on drop. Dropping a batch does not apply deferred
changes or undo successful writes. remove follows the ordinary attachment
operation, including its returned value and codec errors.
Scope, liveness and order
A query covers only the host's current dimension. It does not load chunks or entities, scan other dimensions, or offer cross-dimension joins. Saved values on already-bound holders appear even if no earlier typed read touched them. A well-formed absent key yields an empty batch, without initializing defaults.
A query is a residency view, not an immediate liveness filter. An entity whose removal is queued but has not yet been swept may still appear, even in a fresh query. Its handle's normal stale rules still apply, and stale writes fail. Do not translate a captured scoped handle into an unscoped storage ID to bypass those checks. Passing a host for another dimension does not retarget a retained row.
Traversal follows the dense index's order: insertion appends; removal fills its slot with the final row. The same ordered mutation history is deterministic, but order is neither sorted nor stable across structural changes.
Costs and type identity
Native query acquisition performs one indexed lookup and clones the bucket Arc;
it neither clones T nor decodes values eagerly. Warm borrowed iteration makes
no allocations. A first saved decode may allocate according to the codec and
read cache. Empty native acquisition also allocates nothing.
Retaining a snapshot pins its old bindings. The next live mutation can copy the row metadata and shared pointers once for that retained version; it does not clone each typed value. Repeated snapshots interleaved with writes can therefore cost more, and keeping many batches retains their old values. There is no idle index sweep when no attachment or residency change occurs.
An erased
decoded value can only be read where its Rust type identity matches. A different
native or wasm type produces TypeMismatch; a failed
decoded downcast does not fall back to saved decoding. Share attachment type
identity through the normal dependency model rather than redeclaring a different
type with the same key.
First-store errors
First stores on resident mobs, paintings and dragon parts bind their lifetime
registration. Each actual dragon part owns its own registration: stores never
alias its parent or siblings. Removing or replacing the dragon, or unloading its
physical storage, retires all eight parts even while attachment maps or query
snapshots remain retained. Parking a level preserves its resident parts.
Constructor-listener stores are indexed even when the triggering access was only
an inspection. Physical removal also releases the part's canonical attachment
map and construction state during entity cleanup; immediate network-ID reuse
clears the old part state before admitting its replacement.
Inspection and removal do not create a registration. First-store indexing for
other families is unchanged. A store can return
AttachmentError::HolderBusy while the
engine has lifted or extracted its physical storage: retry after the current
pass. The refused store writes no value and creates no registration.
AttachmentError::UnsupportedHolder(family) means the physical family cannot
own attachments; retrying cannot help. The name is bounded static text, not an
engine type. Wire decoding accepts at most 256 UTF-8 bytes for that text and
rejects longer names on both native and wasm guest paths. Family names are stable lowercase identifiers: mob, item,
orb, projectile, armorstand, itemframe, painting, boat,
minecart, other, fangs, crystal, dragon, tnt, falling,
rocket, trident, spawner, cloud, cushion, knot, player, hook,
and dragonpart.
Retained row writes report the matching AttachmentRowError::HolderBusy or
AttachmentRowError::UnsupportedHolder; a missing holder is instead
StaleHolder.
For an immutable keyed read (including render hooks), capture a snapshot with
host.query(&TYPE)?, then call query.get(holder)? with an AttachmentHolder.
The result is an owned value: Some(value) on a hit and None before sync or
when absent. None does not mean the holder is alive: absent and not-live
(stale or displaced) holders alike return Ok(None) when absent from the
snapshot. This read performs no host liveness check; only scoped residency
probes report StaleHolder. The expected O(1) holder probe runs before decoding, never scans
rows and never initializes defaults. Values require Clone; decode/type errors
use AttachmentRowError::Value. Fresh queries see replacements and removals;
retained queries keep their captured values. Scoped residency tokens still
reject rebound holders with StaleHolder.
Read biome data
Read the level's biome table without loading chunks. A stale level or unknown biome returns None.
use mod_api::{Host, LevelRef};
fn inspect(host: &dyn Host, level: LevelRef) {
if let Some(effects) = host.level_biome_effects(level, "minecraft:plains") {
let water_rgb = effects.water;
// Sky and fog preserve omitted declarations as None. Water fog instead
// uses 0x050533 when omitted. These are not dimension-resolved colours.
let water_fog_rgb = effects.water_fog;
}
if let Some(categories) = host.level_biome_spawners(level, "minecraft:plains") {
for category in categories {
// Categories are sorted by name; entries retain biome-file order.
let entries = category.entries;
}
}
}
The table includes data packs and biome modifiers. Spawners describe the biome's own natural spawn lists, not a position-specific potential-spawn query.
Reading tags in custom loot evaluation
LootFunctionEvent and LootConditionEvent carry tags: &dyn Tags alongside
their owned context and candidate snapshots. This is a read-only view of the
merged tags used by the current loot evaluator, not a full Host.
Resolve an Identifier with event.tags.key(&id), retain the resulting TagKey,
and call is_in_tag(item, tag) for item membership. items(tag), blocks(tag)
and fluids(tag) return ordered members by key. There is no operation to
enumerate every tag. Unknown tags return Some(false) or Some(Vec::new());
unavailable key mappings return None. A retained key keeps its identity across
reloads, but its members are read from the current evaluation's tables.
The same Tags resource crosses Wire for native and wasm callbacks. Neither callback gains a mutable game host or an opportunity to change tags. No tag snapshot is copied when a loot hook has no subscribers.
This is deliberately only tag access. The broader typed read-only loot-context contract, including which optional entity, tool, block and other parameters are available for each evaluation kind, is separate work tracked in #2647.
Register game rule categories
In Hooks::register, call registrar.game_rule_category("machines") to declare a category in your mod's namespace. A full ID in your own namespace is also accepted. Invalid IDs, another mod's namespace and duplicate declarations refuse mod loading, even if the returned error is ignored.
The category's translation key is gamerule.category.<namespace>.<path>. A category appears in the host's in-world game rules screen when it contains a matching rule. Remote clients do not receive categories in this version.
Declare a boolean rule with registrar.game_rule_bool("enabled", "mymod:machines", true) and retain its BoolGameRuleKey. Read it with level.game_rule_bool(host, key). Category references resolve after all mods register; an unknown category refuses loading at finalization. Rule values are saved with the world; values belonging to a missing mod remain in the save. The world-creation rules screen does not list mod rules yet, so a new world starts with their declared defaults. Typed writes and integer rules are planned separately.
Slowing or boosting entities on a block
A Block Part changes how fast, or how high, entities move on the blocks it is attached to, as a block that overrides NeoForge's Block.getSpeedFactor() and Block.getJumpFactor() does. Both Part methods get the loop context of the side that ticks the entity (no Host) and an event with the block state and the factor to edit, which starts at the block's own value (honey is 0.4 speed and 0.5 jump, soul sand 0.4 speed, every other block 1.0).
struct Mud;
impl mod_api::BlockPart for Mud {
fn block_speed_factor(&self, _: &dyn mod_api::LoopContext, event: &mut mod_api::BlockSpeedFactorEvent) {
event.factor = 0.25;
}
fn block_jump_factor(&self, _: &dyn mod_api::LoopContext, event: &mut mod_api::BlockJumpFactorEvent) {
event.factor = 0.5;
}
}
// In `register`: let mud = r.block_key("mymod:mud"); r.block_part(mud, Mud);
The entity reads the block at its feet and, when that gives 1.0 (and, for speed, the block is not water or a bubble column), the block below it. A part's factor is the block's factor in that rule, so 1.0 from your part lets the block below decide, exactly as for a vanilla block.
The player, every mob's ground and jump path, boats and minecarts read the factors where vanilla does. Both hooks fire on both sides and on the client's replayed ticks: register the mod on both and keep each method a pure function of its inputs, or prediction and the server disagree. A mod that wants every block uses living_speed and living_jump_power instead.
See the hook notes for the cost and the details.
Temporary client sound effects
These effects change what a player hears, not the server's world. They do nothing until a mod asks for them. See the API reference for the generated signatures.
Reading the tooltip preference
Read ClientHost::gui_options().advanced_item_tooltips for the current
advancedItemTooltips preference (F3+H). The snapshot reflects the current client
setting on native and wasm; request a new snapshot after a toggle. This is separate
from the advanced flag supplied to an individual tooltip request.
Adding tooltip lines
Use ItemPart::tooltip for an attached item's own lines at the vanilla
Item.appendHoverText position, after the name and before component lines.
Use Hooks::item_tooltip for NeoForge's global ItemTooltipEvent moment: it
receives every item's finished tooltip, including the parts' earlier lines,
component lines and advanced information. No attached part is needed for a
global listener, so this is also how a mod edits vanilla or other mods' tooltips.
The global event supplies the stack, display settings, player IDs, and the
request's advanced and creative flags on native and wasm alike.
Drawing a second world camera
Native internals mods can request extra world cameras through world_views and
consume their textures in world_view_drawn. These are raw renderer places, not
portable API callbacks: wasm mods cannot use them. Construct a WorldView with a
stable ID, camera, yaw, pitch, field of view and pixel size. Return the same
requests whenever the frame asks; at most four valid, distinct IDs draw, with
sides limited to 4096 pixels and the device limit.
Use world_view_drawn to copy a still to your own buffer or draw into the supplied
target with its encoder. Copy a picture you need to preserve: targets are reused
while requested and retired when absent. Views draw before world_drawn, where
you can composite their pictures into the player's frame. Extra cameras increase
rendering work and do not include particles, weather, hands or shader-pack passes.
See the generated world_views hook notes for the full rendering contract and
tools/run_world_views.py for the offscreen fixture.
Muffling sounds
ClientHost::muffle_sounds(amount, lowpass, ticks, exempt) temporarily muffles the sound output, for example to accompany ringing ears after a nearby blast:
amount, from 0 to 1, is the fraction of every sound's volume taken away.lowpass, from 0 to 1, dulls the output, from untouched to a 300 Hz low-pass.- Both effects start at full strength and fade linearly over
ticksclient ticks. The fade waits while the game is paused. - The sound events in
exemptremain clear, including ones already playing. Use this for the mod's ringing sound. Other sounds already playing, music and interface clicks are muffled too.
A new call replaces the current muffle; ticks of 0 ends it immediately. The effect belongs to the sound output, so every splitscreen player hears it. The call refuses an amount or strength outside 0 to 1, or an exempt event that is not a namespaced sound id, without changing the output. Server code cannot call this client-only service.
Play the accompanying sound through ClientHost::play_local_sound with a SoundPlay, rather than a separate legacy sound API.
NeoForge data files
This is a scoped audit of server data, not a claim that carrying a file into a package executes it. The paths and examples below identify the readers and fixtures checked; the modifier gaps are tracked separately.
| Data | Result and reader | Example or evidence |
|---|---|---|
| Convention item tags | Loaded for content roots: mod_content::build in crates/horizon-client/src/mod_content.rs merges tag files; content_namespaces::tag_namespaces in crates/horizon-items/src/content_namespaces.rs includes c for content roots, and items::load_tag_lists in crates/horizon-items/src/items.rs reads and resolves them. Unknown item entries are filtered by Known::keeps in mod_content.rs. | data/c/tags/item/gems/glint.json: {"values":["minecraft:diamond"]}. The glint_mod fixture and a_data_mod_loads_unloads_and_loads_again test in crates/horizon-client/src/mod_content_tests.rs check the resulting c:gems/glint membership. |
| Structure modifiers | Not loaded by any reader found today. mod_content::build copies ordinary files to its overlay; worldgen_entry in that file recognizes worldgen bundles, not structure modifiers. A workspace search for structure_modifier found no reader. Copying is not applying. | No verified in-tree example. #1269 tracks loading and applying them. |
| Global loot modifiers | Not loaded by any reader found today. mod_content::build copies ordinary files to its overlay, but a workspace search for loot_modifiers found no loader. The native_loot_drops code hook in crates/horizon-mod-vocab/src/hooks/base.rs is not a data-file reader. | No verified current-format in-tree example. #1270 tracks loading and applying them. The old global.json in carry::tests::claims_the_namespaces_its_files_name is a carry classification fixture, not proof of current-format loading. |
The shared load-condition evaluator in crates/horizon-world/src/conditions.rs
applies to recipes, loot tables, item modifiers, predicates, enchantments,
jukebox songs, instruments, damage types, advancements, synchronized registry
JSON, world-table tags (including the direct loot and damage readers,
synchronized registry tags and function tags), and native item/block data maps.
The conditional-data contract
describes these file types and their reload lifetimes.
Ordinary JSON resources select the winning pack file before evaluating its
conditions. A false or unsupported condition omits that winner without exposing
a lower pack's file. Tag resources and data maps evaluate each layer before
merge, replace, or remove; an omitted layer cannot clear lower entries.
Content-root composition follows the same tag rule, including block/biome tag
bundles. The damage registry remains frozen after world open; reloads reevaluate
its tags. Generated item default components retain their prototype lifetime.
Supported forms are neoforge:mod_loaded, neoforge:true, neoforge:not
(value), and neoforge:and / neoforge:or (values), inside a
neoforge:conditions array. Every condition must hold; an unknown kind anywhere
in a nested tree omits the file, including beneath not or or. Files without
the array load normally.
mod_loaded reads an immutable snapshot of the owning generation's server-enabled
mod IDs, including code-only native and WebAssembly mods, plus minecraft.
Client-only, disabled and refused packages are absent. Namespaces do not imply a
loaded mod. Changed enabled IDs participate in cache identity and are published
with successful reloads; failed preparation preserves the old generation, and
retained readers keep their old snapshot. Recipe condition behavior is unchanged.
This shared contract does not add readers for the two modifier kinds above.
NeoForge extension methods and Horizon
This page compares the public methods of NeoForge 26.3's object-extension interfaces with what a Horizon mod can answer today. It is the reference the Parts lane starts from: before proposing a new block or item hook, check whether content data, a handle or an existing Part already answers the same question.
Read from NeoForge 26.3.0.101 (neoforge-26.3.0.101-universal.jar, net.neoforged.neoforge.common.extensions)
with javap -p, and from Horizon at commit 061a06c7b. Method lists and parameter names are factual citations; no
NeoForge source or Javadoc is copied. Counts exclude each interface's private self() accessor.
How to read the verdicts
| Verdict | Meaning |
|---|---|
| Covered | A mod can answer the same question for its own object, at the same moment, through content data, a handle or a Part method. |
| Partial | The game lets a mod answer something close, but one of these differs: the object (a global hook instead of a per-block/item method), the moment, the arguments, or the answer (a factor or notice where NeoForge returns a decision). |
| Missing | Neither data, a handle nor a hook exposes the question. |
| n/a | The method has no Horizon equivalent because the architecture removes its reason to exist. |
A Part method is a generated method on BlockPart, ItemPart, BlockEntityPart, MenuPart or EntityPart,
declared with scope in crates/horizon-mod-vocab/src/hooks/<area>.rs. A Part method runs only for the objects a
mod attached its part to. An unscoped hook in the same files is global: every mod hears it and filters by the
arguments itself. Data means a content declaration in crates/horizon-mod-package/src/content.rs, read into
the game through crates/horizon-items/src/blocks.rs. Handle means a value or reference type in
crates/horizon-mod-vocab/src/ (ItemStack, ItemResource, AttachmentStore).
EntityPart exists today but has two methods, register_goals and spawn_cluster_size (docs/modding/src/concepts/trait-mods/023-entity-parts-and-mob-goals.md).
It does not answer IEntityExtension methods; those are covered only where a global hook or a handle does.
Counts
| Interface | Public methods | Covered | Partial | Missing | n/a |
|---|---|---|---|---|---|
IBlockExtension | 60 | 6 | 21 | 33 | 0 |
IBlockStateExtension | 57 | 5 | 20 | 32 | 0 |
IItemExtension | 46 | 9 | 15 | 22 | 0 |
IEntityExtension | 33 | 1 | 16 | 16 | 0 |
ILivingEntityExtension | 6 | 0 | 3 | 3 | 0 |
IBlockEntityExtension | 9 | 1 | 3 | 4 | 1 |
IFluidExtension | 9 | 0 | 6 | 3 | 0 |
IMenuTypeExtension | 2 | 0 | 2 | 0 | 0 |
Of the 60 block methods, 57 exist a second time with the state as receiver, so the state-extension counts are
the block counts minus three; see IBlockStateExtension. The block follow-up is
#1038 and the item follow-up is
#1039. Their groups were written from the earlier audit and
do not name every Missing row yet. Both predate hooks that have since landed, so some of their rows are narrower
or already answered; the last section lists what changed.
The earlier audit's counts included self() and folded the four isInFluidType overloads into one; this page
counts each declared public method, so the entity total is 33 rather than 30.
IBlockExtension
Data fields are static values from a block's content declaration, inherited from its template when unset. They
answer the method for a mod block but cannot vary by level, position or entity. The engine's own copy of the
same facts is StateFacts in crates/horizon-items/src/blocks.rs.
| Method | Verdict | Horizon | Notes |
|---|---|---|---|
getFriction(state, level, pos, entity) | Partial | BlockProperties.friction → StateFacts.friction | Static; the entity is not consulted. |
hasDynamicLightEmission(state) | Missing | — | StateFacts.emission is a fixed level, not a dynamic emission. |
getLightEmission(state, level, pos) | Partial | BlockProperties.light → StateFacts.emission | Static; no level or position. |
ignitedByLava(state, level, pos, direction) | Partial | template state flag (StateFacts, state_flags::IGNITED_BY_LAVA) | Inherited from the template's prepared data; no content field or Part changes it. |
isLadder(state, level, pos, entity) | Covered | BlockPart::is_ladder (hooks/is_ladder.rs) | Asked of the block a living entity stands in, with the entity id, after the #climbable tag: the part's answer replaces the tag's, either way. Players and mobs; armor stands never ask. |
makesOpenTrapdoorAboveClimbable(state, level, pos, stateAbove) | Missing | — | Engine rule in the climbing code; not exposed. |
isBurning(state, level, pos) | Missing | — | No per-block burning query; block_burn is fire removing a block. |
canHarvestBlock(state, level, pos, player) | Covered | BlockPart::can_harvest_block (hooks/can_harvest_block.rs) | Asked where player_harvest_check fires, before it: the global hook still hears and may change the part's answer. |
onDestroyedByPlayer(state, level, pos, player, stack, willHarvest, fluid) | Partial | break_block (hooks/base.rs), block_drops (hooks/block_drops.rs) | break_block cancels the break or replaces the XP sample; block_drops edits drops. Neither returns "the block was destroyed" for the caller. #1038. |
onDestroyedByPushReaction(state, level, pos, direction, fluid) | Missing | — | No hook at the piston/fluid destruction point. |
getRespawnPosition(state, entityType, level, pos, angle) | Partial | player_respawn_position (hooks/base.rs) | The global PlayerRespawnPositionEvent moves the player after vanilla found the bed or anchor; the per-block search is engine-owned. |
getExplosionResistance(state, level, pos, explosion) | Partial | BlockProperties.explosion_resistance → StateFacts.explosion_resistance | Static; the explosion is not consulted. |
getCloneItemStack(level, pos, state, includeData, player) | Covered | BlockPart::clone_item_stack (hooks/clone_item_stack.rs) | include_data and the player are passed; leaving the stack unset keeps the game's item. |
addLandingEffects(state, level, pos, stateBelow, entity, count) | Missing | — | fall_on edits fall damage, not the landing particle/sound burst. |
addRunningEffects(state, level, pos, entity) | Missing | — | No hook for the running dust burst. |
playFallSound(state, level, pos, entity) | Partial | BlockProperties.sounds | Picks a vanilla sound type; no custom event, volume or pitch. |
playStepSound(state, level, pos, entity, volume, pitch) | Partial | BlockProperties.sounds | Same static sound type; step volume and pitch are not settable. |
canSustainPlant(state, level, pos, direction, plantState) | Covered | BlockPart::can_sustain_plant (hooks/can_sustain_plant.rs) | The soil's answer for the plant above it (allow, deny or none), asked before the plant's may_place_on, which still answers for your plant. The direction is always up. |
onTreeGrow(state, level, posConsumer, random, pos, feature) | Missing | — | block_grow_feature observes or edits the feature a sapling grows into; it does not let a block run its own tree growth. |
isFertile(state, level, pos) | Missing | — | No fertile-block query; bonemeal hooks fire later. |
getExpDrop(state, level, pos, blockEntity, breaker, tool) | Partial | break_block.exp_to_drop (hooks/base.rs) | Replaces the XP sample for a player break; global, not per-block, and the block entity/tool/breaker are not arguments. #1038. |
rotate(state, level, pos, rotation) | Missing | — | Structure rotation is engine behaviour; no callback. |
getEnchantPowerBonus(state, level, pos) | Missing | — | Bookshelf enchanting power is a hard-coded block rule. |
onNeighborChange(state, level, pos, fromPos) | Missing | — | neighbor_changed is Block.neighborChanged, a different callback. #1038. |
shouldCheckWeakPower(state, level, pos, direction) | Covered | BlockPart::should_check_weak_power (hooks/should_check_weak_power.rs) | The argument direction is the input side, as NeoForge's. |
getWeakChanges(state, level, pos) | Missing | — | No weak-change observer query. #1038. |
getSoundType(state, level, pos, entity) | Partial | BlockProperties.sounds, host.block_states().sound(state) | Static set of break/step/place/hit/fall sounds; no per-position or per-entity choice. |
getBeaconColorMultiplier(state, level, pos, beaconPos) | Missing | — | Beacon beam colour is engine-owned. |
getStateAtViewpoint(state, level, pos, viewpoint) | Missing | — | No viewpoint-state query. |
getBlockPathType(state, level, pos, mob) | Partial | BlockPart::is_pathfindable (hooks/is_pathfindable.rs) | Answers whether a PathComputationType may pass, once per state and type; not a PathType per mob or position. #1038. |
getAdjacentBlockPathType(state, level, pos, mob, pathType) | Missing | — | No adjacent-path-type query. |
isSlimeBlock(state) | Missing | — | No per-state slime query; the piston/fall logic is engine-owned. #1038. |
isStickyBlock(state) | Missing | — | Same. #1038. |
canStickTo(state, otherState) | Missing | — | Same. #1038. |
isFlammable(state, level, pos, direction) | Partial | BlockPart::fire_odds (hooks/base.rs) | Sets ignite and burn odds (0, 0 = never burns), but there is no boolean flammable predicate. #1038. |
getFireSpreadSpeed(state, level, pos, direction) | Partial | BlockPart::fire_odds (hooks/base.rs) | Ignite/spread odds (the first element of fire_odds's (ignite, burn) tuple) replace vanilla's table for the block. The answer is state-based, with no position or direction as in NeoForge's contextual override. |
getFlammability(state, level, pos, direction) | Partial | BlockPart::fire_odds (hooks/base.rs) | Burn/destruction odds (the second element of fire_odds's (ignite, burn) tuple) replace vanilla's table for the block. The answer is state-based, with no position or direction as in NeoForge's contextual override. |
onCaughtFire(state, level, pos, direction, igniter, stack) | Missing | — | No per-block caught-fire callback. |
isFireSource(state, level, pos, direction) | Missing | — | Fire-source rules are engine-owned. |
canEntityDestroy(state, level, pos, entity) | Missing | — | Mob-grief checks are engine rules; entity_mob_griefing is a global event at a different moment. |
canDropFromExplosion(state, level, pos, explosion) | Partial | explosion_detonate (hooks/base.rs) | Removes positions from the blast before blocks are hit; no per-block keep/drop query. |
onBlockExploded(state, level, pos, explosion) | Partial | explosion_detonate (hooks/base.rs) | Same global pre-blast hook; no per-block callback after the block is removed. #1038. |
collisionExtendsVertically(state, level, pos, entity) | Missing | — | Collision shapes are data; no per-entity extension query. |
shouldDisplayFluidOverlay(state, level, pos, fluid) | Missing | — | Overlay drawing is engine-owned. |
getToolModifiedState(state, context, ability, simulate) | Partial | block_tool_modification (hooks/block_tool_modification.rs) | Global BlockEvent.BlockToolModificationEvent cancels or replaces the final state; not a Part method and no simulate flag. #1038. |
isScaffolding(state, level, pos, entity) | Missing | — | Scaffolding is identified by block class; no content field or Part. |
hidesNeighborFace(level, pos, state, neighborState, direction) | Missing | — | block_render_visibility hides a whole state from the mesher, not one face. |
supportsExternalFaceHiding(state) | Missing | — | No per-face culling query. |
onBlockStateChange(level, pos, oldState, newState) | Partial | block_changed (hooks/base.rs, a global server hook) | Fires after the commit but before block side effects and neighbour updates, and only on an authoritative server. It may not modify the level. |
canBeHydrated(state, level, pos, fluid) | Missing | — | Farmland hydration is engine-owned; fluid canHydrate is data. |
getMapColor(state, level, pos, defaultColor) | Partial | BlockProperties.map_color → StateFacts.map_color | Static; no position. |
getAppearance(state, level, pos, direction, neighborState, neighborPos) | Missing | — | No appearance query. |
getPistonPushReaction(state) | Covered | BlockProperties.push_reaction | Static; PUSH_PULL, PUSH, POPPED, IMMOVEABLE, IGNORE_ENTITY. |
isEmpty(state) | Missing | — | Air/replaceable facts exist (block_states().replaceable), but no isEmpty answer for a state. |
getBubbleColumnDirection(state) | Missing | — | Bubble-column rules are engine-owned. |
shouldHideAdjacentFluidFace(state, direction, fluid) | Missing | — | Fluid-face culling is engine-owned. |
getBounceRestitution(level, pos, state, entity) | Partial | BlockPart::bounce_on (hooks/block_landing.rs) | Edits the entity's movement after it bounced; the restitution factor is not exposed. #1038. |
getRelocability(level, pos, state) | Missing | — | No relocation query (pistons, structure blocks). |
canBeConnectedTo(state, direction, neighborBlock) | Partial | BlockPart::connects_to (hooks/connects_to.rs) | Asks the connecting fence, wall, bars or pane (your block), not the block being connected to: a modded target cannot answer for a vanilla fence, and only those shapes ask at all. |
getModelData(state, level, pos) | Missing | — | No ModelData; block-entity drawing takes a block_entity_submit part with its own model path. |
IBlockStateExtension
This interface repeats the block questions with the state as receiver instead of the first argument. All 57
methods are the same behaviours and take the same verdict as the IBlockExtension row of the same name, with
the parameters shifted by one. Three block methods have no state-receiver form:
| Block-only method | Verdict there |
|---|---|
getPistonPushReaction(state) | Covered (content push_reaction) |
getMapColor(state, level, pos, defaultColor) | Partial (content map_color) |
makesOpenTrapdoorAboveClimbable(state, level, pos, stateAbove) | Missing |
Counts: 5 covered, 20 partial, 32 missing among the 57. The handful of signature differences (canStickTo takes
the neighbour state; getCloneItemStack reorders its arguments; getEnchantPowerBonus takes a LevelReader)
do not change any verdict.
IItemExtension
Item data is a 26.3 component patch over a vanilla template (crates/horizon-mod-package/src/content.rs),
which is the same place NeoForge's item components live. Reads are on host (host/items.rs) or on the owned
ItemStack / ItemResource values.
| Method | Verdict | Horizon | Notes |
|---|---|---|---|
getDefaultAttributeModifiers(stack) | Partial | item_attribute_modifier (hooks/item_attribute_modifier.rs) | Global ItemAttributeModifierEvent edits the stack's modifiers; the stack filters it, but it is not a Part method. |
onDroppedByPlayer(stack, player) | Partial | item_toss (hooks/item_toss.rs) | Global ItemTossEvent, cancellable; the item is not a Part attachment. |
getHighlightTip(stack, name) | Missing | — | No highlight-name hook. |
onItemUseFirst(stack, context) | Covered | ItemPart::on_item_use_first (hooks/on_item_use_first.rs) | Runs after use_item_on_block let it through, before the block's own use. |
isPiglinCurrency(stack) | Missing | — | Piglin barter currency is a hard-coded item tag. |
makesPiglinsNeutral(stack, entity) | Missing | — | Piglin neutrality is a hard-coded item/tag rule. |
isCombineRepairable(stack) | Partial | minecraft:repairable component via ItemResource::repairable | Data answers which material repairs it; the anvil combine predicate is not exposed. |
getXpRepairRatio(stack) | Partial | mending_repair (hooks/base.rs, a global hook) | Changes the repair amount for Mending; the per-item ratio is not a separate read, and the hook is not scoped to the item. |
onStopUsing(stack, entity, timeLeft) | Partial | ItemPart::release_using (hooks/base.rs) | Runs on an early release only, after the vanilla item's release; finishing the use or switching to another item stops it without asking. Both sides. |
onLeftClickEntity(stack, player, entity) | Covered | ItemPart::on_left_click_entity (hooks/item_attack.rs) | Runs where Player.attack starts, after attack_entity let the blow through; a cancel leaves the target unhurt, with no hurt_enemy and no wear. |
getCraftingRemainder(instance) | Covered | ItemPart::crafting_remainder, content crafting_remainder | The per-stack Part override replaces the data remainder in crafting, fuel and quick moves. |
getEntityLifespan(stack, level) | Partial | item_expire (hooks/base.rs) | Adds extra ticks to a dropped item's life; no absolute lifespan and not per-item. |
hasCustomEntity(stack) | Missing | — | No custom dropped-entity selection. |
createEntity(level, entity, stack) | Missing | — | Same. |
onEntityItemUpdate(stack, itemEntity) | Missing | — | No per-item dropped-entity tick. #1039. |
doesSneakBypassUse(stack, level, pos, player) | Missing | — | No sneak-bypass query. |
canEquip(stack, slot, entity) | Partial | equippable.allowed_entities, entity_capability::goes_in_slot | Component data decides slot and allowed entities; no per-stack code predicate. #1039. |
getEquipmentSlot(stack) | Covered | ItemResource::equippable_slot, host.item_equippable_slot | Read from the effective equippable component. |
onEntitySwing(stack, entity, hand) | Covered | ItemPart::on_entity_swing (hooks/item_attack.rs) | Runs where LivingEntity.swing starts, on both sides; a cancel starts no swing (no animation, no packet). |
getDamage(stack) | Covered | ItemStack::damage | The stack's numeric damage override. |
getMaxDamage(stack) | Partial | minecraft:max_damage effective component, host.max_damage | The stack's effective component includes a patch over the item default; host.max_damage reads that default only. |
isDamaged(stack) | Covered | ItemStack::damage, damage_component | Distinguishes explicit zero from absent. |
setDamage(stack, damage) | Partial | ItemStack::set_damage | Stores the requested value; the game checks it against the effective maximum when it accepts the write instead of clamping here. |
canPerformAction(instance, ability) | Partial | ItemPart::can_perform_action (hooks/can_perform_action.rs), block_tool_modification (hooks/block_tool_modification.rs) | The part answers per stack and per namespaced ability id, starting from vanilla's answer, where vanilla checks the item kind: minecraft:shield_block (a raised item blocking attacks, its use time and animation), minecraft:shears_harvest (sheep, mooshroom, snow golem, bogged, bee nests, a dispenser's shears) and minecraft:shears_carve (a pumpkin). The block side of a tool action (strip, wax, path, ...) is block_tool_modification; no tool ability (axe_strip...) is asked of the item yet. #1039. |
getMaxStackSize(stack) | Partial | max_stack_size component, host.max_stack_size | The stack's effective component includes a patch over the item default; host.max_stack_size reads that default only, and invalid values are refused at the write. |
isPrimaryItemFor(stack, enchantment) | Partial | host.enchantment_supports | Answers supported items; the narrower "primary item" distinction is not exposed. |
supportsEnchantment(stack, enchantment) | Partial | host.enchantment_supports | A client-side read of Enchantment.isSupportedItem for an item id; not per stack and without NeoForge's enchanted-book special case. |
getEnchantmentLevel(instance, enchantment) | Covered | get_enchantment_level (hooks/get_enchantment_level.rs) | Edits the level gameplay sees. |
getAllEnchantments(stack, lookup) | Covered | get_enchantment_level with no target | The hook's target is None for every-enchantment reads. |
shouldCauseReequipAnimation(stack, oldStack, equipped) | Missing | — | Client animation rule; no hook. |
shouldCauseBlockBreakReset(oldStack, newStack) | Missing | — | Client mining-reset rule; no hook. |
canContinueUsing(stack, otherStack) | Missing | — | No continue-use query. |
getCreatorModId(registries, stack) | Missing | — | Diagnostic ownership string; Horizon keeps the item's namespace in its key. |
onAnimalArmorTick(stack, level, mob) | Missing | — | No per-item animal-armour tick. |
damageItem(stack, amount, entity, onBreak) | Covered | ItemPart::damage_item (hooks/damage_item.rs) | Runs on the server for the parts of the worn item, after Unbreaking, on every wear path (a player's tools, armour and shield, a dispenser's tool, a mob's armour). The part gets the stack and the amount about to be lost, and changes it (0 or less leaves the item whole) or cancels the wear. It is not given the wearing entity (host.players().acting_player() names the player on a player's paths) and has no onBreak consumer: the break is player_destroy_item. |
onDestroyed(itemEntity, source) | Missing | — | item_despawn is lifetime removal, not damage destruction. |
isGazeDisguise(stack, player, target) | Missing | — | No gaze-disguise query. |
canWalkOnPowderedSnow(stack, entity) | Missing | — | Powder-snow walking is engine-owned. |
isDamageable(stack) | Covered | ItemResource::damageable(host) | Reads the effective components: minecraft:max_damage and minecraft:damage present, minecraft:unbreakable absent. |
getSweepHitBox(stack, player, target) | Missing | — | sweep_attack decides whether a sweep happens, not its box. |
isNotReplaceableByPickAction(stack, player, slot) | Missing | — | Pick-block replacement is engine-owned. |
canGrindstoneRepair(stack) | Missing | — | grindstone_place observes or edits the result; no per-item predicate. |
canBeHurtBy(stack, source) | Missing | — | No item damage-immunity query. |
applyEnchantments(stack, enchantments) | Missing | — | No item-level enchantment application hook. |
canFitInsideContainerItems(stack) | Partial | MenuPart::menu_slot_rules (hooks/base.rs) | Slot rules implement container-item restrictions (a shulker box in a slot); no per-stack override. |
onGlideDamage(stack, entity, slot) | Missing | — | No per-item glide-damage callback. |
IEntityExtension
There is no per-entity Part method yet (EntityPart only registers goals), so entity coverage is data, handles
and global hooks. On the server a mod can act on entities through EntityRef and AttachmentStore.
| Method | Verdict | Horizon | Notes |
|---|---|---|---|
captureDrops() | Missing | — | living_drops edits a dying mob's drops; it does not capture an arbitrary entity's drops. |
captureDrops(collection) | Missing | — | Same. |
getPersistentData() | Covered | AttachmentStore, AttachmentHolder::Entity | Saved and synced attachments replace the writable compound; the data is typed, not raw NBT. |
shouldRiderSit() | Missing | — | Rider pose is engine-owned. |
canRiderInteract() | Missing | — | No rider-interaction query. |
canBeRiddenUnderFluidType(type, rider) | Missing | — | entity_mount fires on mount/dismount, not on fluid riding. |
canTrample(level, state, pos, distance) | Partial | farmland_trample (hooks/farmland_trample.rs) | Global BlockEvent.FarmlandTrampleEvent; per-entity override is not exposed. |
getClassification(includeVehicles) | Partial | host.entity_classification, content entity type | A read of the template's category; a mod entity cannot set a different one per entity. |
isAddedToLevel() | Partial | entity_join_level / entity_leave_level, host.entity_added_to_level | State is tracked by the game; the handles tell a mod after the fact. |
onAddedToLevel() | Partial | entity_join_level (hooks/base.rs) | Fires after the entity is in the level's lists; cancellable, unlike the method. |
onRemovedFromLevel() | Partial | entity_leave_level (hooks/base.rs) | Fires after removal and only listens. |
revive() | Missing | — | No entity revive API. |
isMultipartEntity() | Missing | — | The dragon's parts are internal to the engine. |
getParts() | Missing | — | Same; EntityRef names dragon parts in some reads but does not expose the array. |
getFluidTypeHeight(type) | Missing | — | Fluid properties are per-fluid data; no per-entity height. |
getMaxHeightFluidType() | Missing | — | Same. |
isInFluidType(state) | Partial | host.entity_is_in_water, entity_is_in_lava | Only water and lava answered; no arbitrary fluid. |
isInFluidType(type) | Partial | host.entity_is_in_water, entity_is_in_lava | Same. |
isInFluidType(predicate) | Partial | host.entity_is_in_water, entity_is_in_lava | No predicate form. |
isInFluidType() | Partial | host.entity_is_in_water, entity_is_in_lava | "Any fluid" is water-or-lava only. |
getFirstEyeInFluidType() | Missing | — | Eye position per fluid is engine-owned. |
isEyeInFluidType(type) | Missing | — | Same. |
canStartSwimming() | Missing | — | Swimming start is engine-owned. |
getFluidMotionScale(type) | Missing | — | No per-entity fluid scale. |
isPushedByFluid(type) | Partial | FluidTypeProperties.can_push_entity | Per-fluid data, not a per-entity answer. |
canSwimInFluidType(type) | Partial | FluidTypeProperties.can_swim | Per-fluid data. |
canFluidExtinguish(type) | Partial | FluidTypeProperties.can_extinguish | Per-fluid data. |
getFluidFallDistanceModifier(type) | Partial | FluidTypeProperties.fall_distance_modifier | Per-fluid data. |
canHydrateInFluidType(type) | Partial | FluidTypeProperties.can_hydrate | Per-fluid data. |
getSoundFromFluidType(type, action) | Missing | — | No fluid sound-action table. |
sendPairingData(player, consumer) | Missing | — | No pairing-payload API. |
copyAttachmentsFrom(entity, wasDeath) | Partial | attachments survive dimension change; player_clone (hooks/base.rs) | Copy rules are the attachment store's; there is no per-entity hook at the copy. |
getBlockBounciness(pos, state) | Partial | BlockPart::bounce_on (hooks/block_landing.rs) | Edits post-bounce movement instead of returning a bounciness factor. |
ILivingEntityExtension
| Method | Verdict | Horizon | Notes |
|---|---|---|---|
canSwimInFluidType(type) | Partial | FluidTypeProperties.can_swim | Per-fluid data, not per living entity. |
jumpInFluid(type) | Missing | — | Fluid jump behaviour is engine-owned. |
sinkInFluid(type) | Missing | — | Same. |
canDrownInFluidType(type) | Partial | living_drown (hooks/base.rs), FluidTypeProperties.can_drown | The global drown event can cancel or change damage; fluid data decides whether it applies. |
moveInFluid(type, movement, fallDistance) | Missing | — | Movement in fluid is engine-owned; no per-entity method. |
onDamageTaken(container) | Partial | living_damage_post (hooks/living_damage_post.rs), living_incoming_damage (hooks/base.rs) | Global server hooks: the incoming container before the hit, and a post-damage notice only once health dropped, not a per-entity callback for every hurt. |
IBlockEntityExtension
Block entity parts are attached with r.block_entity_part; BlockEntityPart extends BlockPart and carries the
block-entity hooks (crates/horizon-mod-vocab/src/hooks/ with scope = "block_entity"). Saved data and attachments
are reachable through BlockEntityRef.
| Method | Verdict | Horizon | Notes |
|---|---|---|---|
onDataPacket(connection, input) | Partial | BlockEntityPart::block_entity_update_tag (hooks/base.rs) | The server half (getUpdateTag) is settable; the client's merge of the received tag is engine-side. |
handleUpdateTag(input) | Partial | BlockEntityPart::block_entity_update_tag (hooks/base.rs) | Same: the data is covered, the client callback is not. |
getPersistentData() | Covered | AttachmentStore, AttachmentHolder::BlockEntity | Typed saved/synced attachments replace the writable compound. |
onChunkUnloaded() | Missing | — | chunk_unload is the chunk event; there is no per-block-entity callback. |
onLoad() | Partial | BlockEntityPart::block_entity (hooks/base.rs) | Initial saved data is set at creation, before the saved NBT is applied; no after-load notice. |
requestModelDataUpdate() | Missing | — | No model-data invalidation API. |
getModelData() | Missing | — | No ModelData; drawing uses block_entity_submit and the model path. |
invalidateCapabilities() | n/a | capability providers are queried per lookup (crates/horizon-mod-vocab/src/parts/block_entities.rs) | There is no cached handler to invalidate. |
applyStructureRotation(mirror, rotation) | Missing | — | Structure rotation is engine-owned; no callback. |
IFluidExtension
Fluid type facts are one record, FluidTypeProperties (crates/horizon-mod-vocab/src/block/fluid_states.rs), read with
host.fluid_states().type_properties(key). They are static per fluid, not per position or block, and they are
derived today: the record describes water's and lava's families and gives every other fluid inert values
(crates/horizon-client/src/game/modding/native_block_states.rs). A mod's own fluid declares a sprite and tint,
not its type properties, so "partial" below means the vanilla answer is readable but a mod fluid cannot set it.
| Method | Verdict | Horizon | Notes |
|---|---|---|---|
getExplosionResistance(state, level, pos, explosion) | Missing | — | No fluid explosion resistance. |
getFluidType() | Partial | host.fluid_states().type_properties | Reads water's and lava's type record; a mod's own fluid cannot declare one and reads as inert. |
canConvertToSource(state, level, pos) | Partial | create_fluid_source (hooks/create_fluid_source.rs) | Global CreateFluidSourceEvent; not per-fluid and not a Part. |
supportsBoating(state, boat) | Partial | FluidTypeProperties.supports_boating | Static, and only a water-family fluid supports boating; a mod fluid cannot opt in. |
getBlockPathType(state, level, pos, mob, canFloat) | Partial | BlockPart::is_pathfindable (hooks/is_pathfindable.rs) | Block-level path computation type only; no fluid PathType. |
getAdjacentBlockPathType(state, level, pos, mob, pathType) | Missing | — | No adjacent fluid path type. |
canHydrate(state, level, pos, farmland) | Partial | FluidTypeProperties.can_hydrate | Static, and only a water-family fluid hydrates; a mod fluid cannot opt in. |
canExtinguish(state, level, pos) | Partial | FluidTypeProperties.can_extinguish | Static, and only a water-family fluid extinguishes; a mod fluid cannot opt in. |
appendHoverText(stack, context, display, lines, flag) | Missing | — | FluidStack has no tooltip hook. |
IMenuTypeExtension
Menu types are content data (MenuType in crates/horizon-mod-package/src/content.rs, files under
data/<id>/horizon/menu/), and the game's own menu engine opens them (menu::Kind::Mod). A MenuPart can answer
slot rules, not construct a menu.
| Method | Verdict | Horizon | Notes |
|---|---|---|---|
create(IContainerFactory) (static) | Partial | content MenuType declaration | Declares slot count, data slots and take-only slots, but cannot supply a factory that builds the client menu from server-sent data. |
create(windowId, inventory, buffer) | Partial | menu engine + MenuPart::menu_slot_rules | The engine builds the menu from the declaration; a mod cannot run code at construction. |
What the follow-up issues cover
#1038 (blocks) and #1039 (items) were written from the earlier audit. Some of their rows have landed or become partial since and should be narrowed when they are filed:
- Changed on the block side:
shouldCheckWeakPowerandshould_redstone_wire_connect_to(the issue says reuse them) are Parts;getFlammability/getFireSpreadSpeedare nowfire_odds(state-based, without a position or direction);canSustainPlantiscan_sustain_plant(the soil) beforemay_place_on(the plant);canHarvestBlockiscan_harvest_blockbeforeplayer_harvest_check;isLadderisis_ladder;getToolModifiedStateis partlyblock_tool_modification. - Changed on the item side:
getEquipmentSlotisItemResource::equippable_slot;getCraftingRemainderhasItemPart::crafting_remainderplus content data andhost.item_crafting_remainder;canEquiphas theequippablecomponent;getXpRepairRatiohas a globalmending_repairthat changes the amount. - Block group rows that still need their own answer:
getExpDrop,onBlockExploded,getWeakChanges,onNeighborChange,getBounceRestitution,isSlimeBlock,isStickyBlock,canStickTo,onDestroyedByPlayer(all Partial or Missing above). - Item group rows that still need their own answer:
onEntityItemUpdate,canPerformAction(both Partial or Missing above). - Missing but not in either group: the remaining block, entity, block-entity and fluid rows marked Missing above; they have no follow-up issue yet. The largest are the entity fluid queries, block entity model data and fluid tooltips.
Mod files
A .hmod is one mod: content, native code, or both. horizon_mod_package::Package reads the shared manifest and
validates the package; horizon_mod_package::Content reads its resources, and horizon_mod_package::index::native::Bundle
reads its code. Content is preview, native code experimental (data-formats.md).
The zip
- A zip archive. Entries are stored or deflate-compressed, the only methods the readers support.
- A path is relative and
/-separated: no\, no:, no empty,.or..part, and it doesn't start with/. Files must be regular files, not symlinks, devices or executables. Executable payloads are refused even under resource paths. - Each path appears once. Directory entries carry no payload.
Project and archive layout
my_mod.hmod
├── horizon-mod.toml
├── icon.png # optional
├── assets/my_mod/
├── data/my_mod/
├── Cargo.toml # code mods only
├── src/lib.rs # code mods only
└── mod.wasm # wasm mods only
- Content only: no
Cargo.tomlor[native]. Nothing compiles. - Native code: root
Cargo.tomlandsrc/, with[native]in the same manifest. The launcher builds it into the game. Its resources load through the same content reader as a content mod's. - Wasm code:
mod.wasmat the top, declared by[wasm], with theCargo.tomlandsrc/it was built from (a converted mod recordsbuilt_withinstead and carries no source). The game runs it with no rebuild. - Inconsistent code declarations and files refuse the package.
mod.wasmanywhere else, undeclared, or declared but missing, refuses it; so does any other executable file.
There is no mod-id directory level inside the archive. horizon-mod.toml is the only manifest and the only
source of the mod's id and version; Cargo's package name and version are not mod identity. IDs are plain
[a-z0-9_]{1,64} names, also used as resource namespaces. The launcher writes horizon-mixin.toml in its
staging area when needed. Hand-written tool manifests, horizon-native.json, and input native/ or rust/
folders are unsupported.
horizon-mod.toml
The shared manifest fields include id, name, version, api = "0.2"
and capabilities = ["registries"]. The content API is checked for exact equality. Unknown fields
refuse the manifest. Native mods may declare top-level internals, an array of tables with
path, hash, and optional kind when a path names several member kinds. Native code adds [native]:
| Field | Type | Rule |
|---|---|---|
game | string | not empty, the Minecraft version ("26.3") |
horizon | string | the exact Horizon commit, 40 lowercase hex characters |
mixins | string | the exact horizon-mixins commit, 40 lowercase hex characters |
module | array of tables | optional support modules, written as [[native.module]] |
Both access modes use the same top-level declarations.
API function listeners use top-level [[inject]]: id, at, lowercase kind, with, and hash,
with optional access (default api) and priority (default 0). head and return are supported
for API slots; internals slots also accept the listed call kinds with call, nth and ctx.
Unknown fields are refused by the schema. API function declarations use only [[inject]].
See Injections for the fields and point injections, and
Native mods for the named hook contracts.
A module has a unique, nonempty id (one id space with the injections), a file naming the game source
file that receives it, a name that is a Rust module identifier, and a source payload file under
src/, holding the module's items without an outer wrapper. An optional visibility is exactly
"pub" or "pub(crate)", the default. It is forwarded to horizon-mixins as [[module]]; see
Support modules.
A wasm mod adds [wasm] instead, written by cargo horizon-mod build --wasm
(Building to mod.wasm):
| Field | Type | Rule |
|---|---|---|
sha512 | string | mod.wasm's SHA-512, 128 lowercase hex characters |
toolchain | string | not empty, the Rust toolchain it was built with |
linked | table | optional, dependency id → the SHA-512 of the mod.wasm it was linked against |
built_with | string | optional, not empty: the converter that made it, for a package with no source |
At load mod.wasm must start with \0asm, be at most 64 MiB and match sha512; each failure refuses the
package with its reason.
Top-level [dependencies] declares other mods by id: a semver requirement string, or
{ version = "^1", optional = true }. These versions refer to horizon-mod.toml, not Cargo metadata.
Content layers load in dependency order. A missing required dependency, incompatible installed version or dependency cycle refuses startup; an absent optional dependency is ignored. Native code dependencies additionally require API packages
(Dependencies between trait mods).
Original Java jar layers
Content and code mods can list ordered [[jar]] references. Use
cargo horizon-mod use-jar to record the
original id, version and exact file's sha512, without copying it into the mod.
Pack order. From bottom to top, assets use vanilla, then each mod's jars in
[[jar]] order, then that mod's own files, with later mods above earlier ones and
player resource packs above all mods. The mod's own files win over its jars for
the same path (the log lists each duplicate asset path once at load). Files every
resource pack stacks (atlases, lang, sounds, blockstates) merge in that same order
through the normal pack merger.
Data uses vanilla, then each mod's jars in [[jar]] order and that mod's own
data/, then the world's enabled datapacks. Later mods override earlier ones,
tags merge with their normal replacement rules, and a later recipe replaces an
earlier recipe at the same id. A jar may use any namespace. Only its passive
assets/ and data/ participate: Java classes and the jar's own pack metadata are
not loaded.
Original jars stay in <game home>/jars/<sha512>.jar; no bytes are copied into a
mod. The launcher checks and supplies exact files, and the existing ZIP readers
write only the decoded resource cache. The complete ordered reference list,
including version, contributes to the composed root's stamp, so changing it
rebuilds on the next resource load. Server /reload uses the accepted inputs of
that composed root, not another package scan.
Unavailable originals. Launcher preparation refuses before a native build or
game launch and names all requiring mods, the original jar and its expected
cache path. Direct game resource or world loading with a missing native
requirement fails as a whole; it never publishes code without the matching data.
A missing input during /reload reports the typed failure to the command source
and keeps the previous data and composed root. Content-only mods have no code to
strand: each whole mod can instead be refused, with the reason in its Mods screen panel, including
every owner of a shared missing file. The game never downloads jars.
Source paths
A code package contains root Cargo.toml, optional build.rs, .rs and .toml files under src/,
and root text files named README*, LICENSE*, LICENCE*, COPYING* or NOTICE*.
The marked root impl Hooks uses #[mod_api::horizon_mod]; the attribute generates the factory
(Trait mods).
Folder packaging selects source and resource files, excluding .git/ and target/. It validates selected
files before copying them. Whole archives are checked, not treated as source folders with ignored extra entries.
Content paths and icon
assets/ or data/, then at least two parts of [a-z0-9_.-] (vanilla's resource-location characters).
Code and content use the same top-level resource trees; there is no separate native data folder.
icon.png is optional for both kinds. It has no manifest key. The package reader fully validates it once:
a PNG, nonzero square dimensions, at most 512 × 512 pixels and 256 KiB encoded. The launcher shows it in
the mod list. The scaffold does not create a default icon.
Limits
| What | Limit |
|---|---|
| A registry file, as downloaded | 64 MiB |
| Selected native source files, excluding the manifest, icon and resources | 511 files, 64 MiB unpacked in total |
| Content files | 65,536 files, 16 MiB each, 512 MiB in total |
horizon-mod.toml | 32 MiB |
icon.png | 256 KiB encoded, 512 × 512 pixels |
The registry index and .hpack
Horizon's registry lists what is made for Horizon and reviewed: content mods (.hmod), native mods (.hmod,
native mods), modpacks (.hpack), resource packs, data packs, shaders-folder packs and Horizon shader packs (.hshader,
shaders.md). A project is listed by a reviewed pull request in
horizon-rust/horizon-mods; new versions are submitted as files and pinned by SHA-256. Legacy entries may still follow their repositories. The launcher and the
website read one file, index.json, which that repository's CI builds. The Rust types and every check below are
horizon_mod_package::index and horizon_mod_package::modpack (feature index); the sample is
crates/horizon-mod-package/tests/index/index-v1.json, read by the tests with the files it lists.
Level: preview (API stability). Schema 1.
index.json
{
"schema": 1,
"generated": "2026-10-24T18:00:00Z",
"projects": [{
"id": "coords_hud", "kind": "mod", "name": "Coordinates HUD", "summary": "One line.",
"description": "Markdown.", "authors": ["Horizon"], "owners": ["ludovic111"],
"license": "MIT", "source": "https://github.com/...", "icon": "https://...png", "key": "RWT...", "downloads": 1234, "stars": 56,
"categories": ["utility"], "tags": ["hud"], "screenshots": ["https://...png"],
"versions": [{
"version": "0.1.0", "published": "2026-10-24", "changelog": "",
"api": "0.2", "side": "client", "capabilities": ["registries"], "game": "26.3",
"file": { "name": "coords_hud-0.1.0.hmod", "url": "https://github.com/horizon-rust/horizon-mods/releases/download/...",
"size": 438, "sha256": "e113...", "signature": "untrusted comment: ...\n...\ntrusted comment: ...\n...\n" }
}]
}],
"revoked": [{ "id": "bad_mod", "sha256": "1111...", "reason": "Sent chat messages it didn't declare.", "date": "2026-10-24" }]
}
| Field | Meaning |
|---|---|
schema | 1. A reader refuses a higher number with "update Horizon" rather than misread it. |
projects[].id | [a-z0-9_]{1,64}, unique; a mod's is its manifest's id. |
kind | mod (content), native-mod, modpack, resourcepack, shader (Horizon .hshader), shaderpack (a shaders/-folder zip), datapack (a data-pack zip). Other kinds (worlds, themes) may come without a new schema: a reader keeps a kind it doesn't know and doesn't offer it. |
summary, description | One line for lists; Markdown for the project's page, required for a listed project and 50–10,000 characters after trimming. |
owners | GitHub logins allowed to publish updates. |
license, source | An SPDX expression and an https:// address of the source code: both required. |
icon | Required for a listed project. An HTTPS URL in the generated index, from the registry's icons/<id>.png (not a remote image input). |
categories | 1–3 from the kind’s category list for a listed project. |
tags | Up to 10 lowercase tags. |
screenshots | Optional, up to 8 HTTPS PNG or JPEG URLs in the generated index, from files kept in the registry under screenshots/<id>/. |
key | The author's minisign public key. When set, every version must be signed with it; an update signed with another key is refused. Absent: the project is unsigned and the launcher says so. |
versions | Newest first, at least one. |
downloads | How many times its files were downloaded, every version together: GitHub's count of each release asset, summed by the publish job, which runs every hour. Absent until a file is downloaded. Read-only: never in an entry. |
stars | GitHub stars of the project's source repository, refreshed by the publish job every hour. Absent when zero or the source is not on GitHub. Read-only: never in an entry. The launcher shows the count and a button that opens the repository. |
api | A mod's API line (its manifest's api); a native mod's pinned revisions (native-1/<horizon>/<mixins>); a modpack's manifest format (1). |
side, capabilities | A mod's, read from its manifest by CI, never typed by hand. |
game | The Minecraft version it is for. |
requires | A modpack's mods: { id, version, sha256 }, each a version in this index with that hash. |
file | A plain name with the kind's extension (.hmod, .hpack, .zip, .hshader), an https:// URL (the registry's release https://github.com/horizon-rust/horizon-mods/releases/download/<id>-<version>/<name>, or an asset of the project's own repository, https://github.com/<owner>/<repo>/releases/download/<tag>/<name>: the launcher accepts exactly these shapes), the size (at most 64 MiB), the lowercase hex SHA-256, and the .minisig text when the project has a key. |
revoked | Files taken down: one file by sha256, or every version of id without it. The launcher refuses to install them and turns installed copies off with the reason. |
Readers ignore fields they don't know, so an optional field can be added to schema 1. Renaming or removing a field, or changing what one means, makes schema 2.
Each version may list native_binaries, an array of records with build (the
exact game build ID, at most 128 bytes with no ASCII control characters), target (the canonical Rust target triple), and file.
The file uses the index's existing name, URL, size, SHA-256 and minisign signature
fields; the signature is required. Records live only in the registry, never in
horizon-mod.toml. Duplicate build/target pairs are rejected. Lookup returns a
typed absence when the exact pair is unavailable, so source preparation can
handle it without selecting another build or architecture. These signatures are
registry-produced metadata, independent of project package keys. The launcher
builds native mods locally with its pinned Rust toolchain against the prebuilt
game; registry-built native binaries are not used.
Before a file is installed
index::check_file runs on the downloaded file, before it reaches the game folder:
- Its size and SHA-256 are the ones the entry lists, and it isn't revoked.
- With a project key: its signature verifies with that key (minisign's prehashed and legacy signatures both).
- What the file says matches the entry: a mod's
horizon-mod.toml(id, version, API, side, capabilities) and its content's fence (making a content mod); for code, the same manifest's[native](game, the exact Horizon and horizon-mixins revisions) and checked root Cargo project; a modpack's manifest (id, version, format, game, mods); a resource pack'spack.mcmeta; a shader pack'shorizon_shader_api::checkand manifest (id, version, shader API). - A kind this build can't install is refused with "this Horizon can't install a … yet".
.hpack
A zip holding horizon-modpack.toml and, optionally, overrides/. The mods are not inside: the launcher downloads
each from the index and checks it as above.
format = 1
id = "cozy" # [a-z0-9_]{1,64}: the profile's folder
name = "Cozy"
version = "1.0.0"
description = "Coordinates and a calm render distance."
authors = ["Horizon"]
game = "26.3"
[[mods]]
id = "coords_hud"
version = "0.1.0"
sha256 = "f891bde88f4b841fd35c4f3305bc8904b671ebe9d5ad28d5aecc57e8b23fc3ce"
on = true # optional, default true: turned on in the profile's horizon-mods.txt
Unknown keys are refused. overrides/ may hold only options.txt, horizon-graphics.txt, horizon-extras.txt,
resourcepacks/<name>.zip and horizon-mods/.horizon-mod-data/<a listed mod>/... (a mod's own saved settings,
a native mod's saved settings), each at most 64 MiB and 256 MiB in all; anything else refuses the pack. The launcher copies
them into the profile's game folder.
The registry repository
horizon-rust/horizon-mods holds one mods/<id>.toml per project and
revoked.toml. An entry is a page and its pinned versions: new versions are submitted as files and checked. Legacy entries may name a repository whose releases are read as below; new repository-following entries are refused. What an entry can't vouch for is read from the file by the kind's own
check (index::inspect): a mod's API, side and capabilities, a modpack's mods, the size. Types and rules:
horizon_mod_package::index::registry.
Every listed project's resolved page requires a description of 50–10,000 characters after trimming, 1–3 categories and an icon stored in the registry. Icons and optional screenshots are registry-local input paths, not remote HTTPS images; the generated index exposes their registry-hosted HTTPS URLs.
id = "coords_hud" # the file's name, and the manifest's id
kind = "mod" # mod | native-mod | modpack | resourcepack | shader | shaderpack | datapack
repo = "horizon-rust/coords-hud" # GitHub owner/name: its releases are its versions
owners = ["ludovic111"] # GitHub logins responsible for the project
owner_ids = [12345678] # their numeric GitHub IDs: these decide who an owner is (a login can change hands)
key = "RWT..." # optional: the minisign public key every version is signed with
icon = "icons/coords_hud.png" # required: square PNG, 64–1024 pixels, at most 256 KiB
categories = ["utility"] # 1–3 from the kind’s list
tags = ["hud", "coordinates"] # up to 10 lowercase tags
screenshots = ["screenshots/coords_hud/1.png"] # optional: up to 8 local PNG/JPEG files
# The page. Each field may instead come from horizon-registry.toml in the repo (below); these win.
name = "Coordinates HUD"
summary = "One line, at most 160 characters."
description = """Shows your current coordinates in a compact HUD so you can find your way back to saved locations."""
authors = ["Horizon"]
license = "MIT"
# source defaults to https://github.com/<repo>
Legacy: versions from releases. For each entry with a repo, the registry's CI lists its GitHub releases (drafts
and pre-releases are skipped) and, for each release it has not read yet, downloads the asset with the kind's extension
(and its <name>.minisig when the project has a key), runs the checks of a submission (below), and if they pass
adds the version. The version, the game and the pins are the file's own manifest (horizon-mod.toml
for both content and code, horizon-modpack.toml, the shader manifest); the release's tag stands in where the file
names none (a leading v is dropped). published is the release's date, changelog its notes (cut at 6000
characters), file.url the asset's address in the repository, file.sha256 and size are computed by the CI and, when GitHub
gives the asset a digest, must equal it. Tags and asset names use letters, digits, ., -, _ and +. A release
that fails a check is skipped and reported in an issue of the registry; the same bytes are not read again. The versions that passed are kept,
in the order they were read, in versions/<id>.json on the registry's versions branch (the page last read from
the repository is kept with them), so the history is auditable and the index does not change when a repository
does: a release deleted or an asset replaced later does not change the index (the launcher refuses bytes that are not the pinned ones).
horizon-registry.toml at the root of the repository, as of the newest release's tag, may hold name, summary,
description, authors and license (unknown keys, and anything about owners, key, kind or icon, are refused): they fill the
page fields the entry leaves out. A listing that is incomplete without it is refused until it is.
[[versions]] hold submitted versions, including Horizon's own mods built in the registry (native/<name>/):
[[versions]] # newest first
version = "0.1.0" # letters, digits, '.', '-', '_': the release tag is <id>-<version>
published = "2026-10-24"
game = "26.3"
file = "coords_hud-0.1.0.hmod" # the name players download, a release asset of the registry
sha256 = "e113..." # pins the reviewed file
signature = """untrusted comment: ...""" # when the project has a key
changelog = "" # optional
The index lists both kinds, newest first. A project listed by its repository with no release yet is not in the index.
# revoked.toml
[[revoked]]
id = "bad_mod"
sha256 = "1111..." # optional: without it, every version
reason = "Sent chat messages it didn't declare."
date = "2026-10-24"
A project is reviewed when it is listed. New versions are checked and reviewed, except an unchanged page's qualifying owner update as described below. Legacy repository-following entries keep their existing release process. revoked.toml takes a bad version down.
The submission form's service (crates/horizon-mod-registry) signs the creator in with GitHub, accepts the page and version file, and opens a pull request. It does not take updates for a legacy project listed with a repo: "publish a GitHub release in repo can be updated through the form
(it uploads the file to the repository's staging release as <sha256>.<extension> and opens a pull request; an
owner's update with nothing else changed is merged by owner-update once the checks pass; only an owner whose numeric
GitHub ID is in owner_ids gets that, and an entry without owner_ids matches its owners by login and always waits for
a reviewer). The registry's deployment contract is owner-update-v2.md.
A project's kind never changes. A submission that GitHub broke off after
making its submit/<id>-<version> branch can be sent again: the same account's retry starts that branch again from
main while it has no open pull request; anyone else's is refused.
Unknown keys in an entry are refused. Its CI runs cargo-horizon-mod (crates/cargo-horizon-mod):
| Command | What it does |
|---|---|
cargo horizon-mod internals sync | Explicitly checks a native mod's library through the launcher against an isolated copy of its pinned game source, then replaces top-level internals with compiler-discovered paths, kinds and hashes. Handles globs and macros, removes unused declarations, preserves retained entries and prints additions/removals. Requires a trusted mod and horizon-launcher on PATH (or HORIZON_LAUNCHER set to its executable). Never runs automatically on build. |
cargo horizon-mod internals add <path>… | Adds each declared internal and its tool-written hash to top-level internals in the current mod's horizon-mod.toml. Reads the native mod's pinned prebuilt game's internals.json; unknown paths, shape-only entries and paths available only under a cfg are refused before the manifest is changed. Existing declarations are refreshed, not duplicated. |
cargo horizon-mod update [--horizon <commit>] | Moves the native game's pin and its mod API and internals dependencies' rev to the newest prebuilt release (or the exact commit given). For every declared internal, prints missing, changed (old signature → new signature), or unchanged, then refreshes the hashes still available. Missing or now-gated declarations keep their old hash and must be removed or replaced before building. |
cargo horizon-mod check <file> [--json] | A submission's checks: its kind's (above) and its signature with --key and --signature. It runs no mod's code: a native mod's source is read by the reviewers when it is listed. |
cargo horizon-mod registry check <base> <head> --files <dir> | check on each hosted file head lists and base doesn't: a pull request's new files. |
cargo horizon-mod registry list <checkout> | <sha256> <id> <version> <file> per hosted version: the files to download. |
cargo horizon-mod registry build <checkout> --files <dir> --out index.json | Reads every entry, its hosted files (<dir>/<sha256>) and versions/<id>.json, lists every problem, and writes the index (--release, ending in /, defaults to the repository's releases, for hosted versions). |
cargo horizon-mod registry release <checkout> <id> <file> --tag T --published D [--notes F] [--signature F] [--game G] | The checks of a submission, then the version a release of the project's repository is, as the index lists it (JSON). |
cargo horizon-mod registry page <checkout> <id> <horizon-registry.toml> | The repository's page file, checked against its entry (JSON). |
cargo horizon-mod registry review <base> <head> --author <login> | A pull request's changes: a published file never changes and is never removed (take it down in revoked.toml), a kind never changes; changes by a non-owner and to owners, keys or the repository are pointed out for the reviewers. |
The native-mod commands run in the mod's project folder and preserve the manifest's comments and
layout. sync performs a metadata-only cargo check on the current host, using default features;
Rust build scripts and procedural macros run with your rights, but the game does not run. It keeps
a separate per-pin source and check cache in the launcher's native/internals-sync/<commit>/ folder,
never in the normal game or prebuilt target directories. The pin's source must be cached or
downloadable; an unavailable source is E_INTERNAL_SOURCE_UNAVAILABLE, not a fallback to another
pin. Compiler or catalog errors leave the manifest untouched. Raw listeners retain their generated
typed constructors; source-routed listeners and support modules are explicitly refused, so their
manually declared internals cannot be accidentally removed. Use internals add for those mods.
internals add and update download the same prebuilt archive layout as the launcher, check its SHA256SUMS, and
read only internals.json; no game or mod code is run. Catalogs are kept by commit in
target/horizon-mod/prebuilt/, so update can still show the old signatures after that release expires.
If a needed catalog is neither cached nor downloadable, the command fails without changing the pin
or declarations. A mod with no declared internals needs no catalogs to update. Quote paths containing
Rust lifetimes or other shell metacharacters, for example:
cargo horizon-mod internals add 'horizon_client::game::Game' "horizon_client::game::maps::Game<'_>::dimension_id"
cargo horizon-mod internals sync
cargo horizon-mod update
Declared internals and raw listeners explain when to use this escape hatch instead of the typed mod API.
How the repository runs (its workflows are in .github/workflows/):
- A pull request (
check.yml) runsregistry review, downloads each new hosted file from thestagingprerelease, runsregistry checkon it and builds the index it would publish; the report is the run's summary. The tool iscargo-horizon-modfrom Horizon at the commit in the repository variableHORIZON_REV. - Every hour, and on a push to
main(publish.yml): read the new releases of every entry with arepoas above and keep the result on theversionsbranch, make each new hosted version a release<id>-<version>, and deployindex.jsonandicons/to GitHub Pages:https://horizon-rust.github.io/horizon-mods/index.json, served withAccess-Control-Allow-Origin: *. - Merging an entry needs the
checkrun and a CODEOWNER's review. - Weekly,
cleanup.ymldeletes staged copies of published files after 7 days and of files no open pull request lists after 30. - Taking a file down is a pull request adding it to
revoked.toml. Removing an entry outright (a test, a project nobody installed) failsregistry reviewon purpose and needs an administrator's merge.
Kinds open to submissions: mod (content only), native-mod, modpack, resourcepack, shader, shaderpack, datapack. A data pack contains pack.mcmeta and data/; a shaderpack contains shaders/ and requires redistribution permission. A Horizon shader pack (.hshader) passes
horizon_shader_api::check, the check the game runs before it loads one (horizon-shader check, shaders.md):
every stage validated by naga and translated for Vulkan, Metal and DirectX 12; its manifest's id, version and shader
API line are its facts. A new kind gets its own check before it opens.
Changelog
- 2026-11-08: per-version
native_binariesrecords index native mod files by exact game build and Rust target triple. - 2026-11-05:
projects[].starsadds the GitHub source repository's star count without changing schema 1. - 2026-11-05: every listed project needs a description of 50 characters or more, 1 to 3 categories and an icon kept in the registry (
icons/<id>.png); screenshots are kept in the registry too (registry buildrefuses https:// images in entries). - 2026-10-30: an entry names its GitHub repository (
repo) and its versions are the repository's releases, read by the registry's CI;file.urlmay be a release asset of that repository. The index's schema is unchanged. - 2026-10-26:
projects[].downloads, GitHub's download counts of a project's files, refreshed every hour. - 2026-10-26: the launcher installs
shaderfiles intoshaderpacks/; the sample index lists one (glow). - 2026-10-25:
shaderopens, checked byhorizon_shader_api::check; its files are.hshader. - Schema 1 (2026-10-24): kinds
mod,modpack,resourcepack,shader;.hpackformat 1.
The mod API (rustdoc)
The reference for every hook, event, handle and Host method is the rustdoc of the horizon-mod-api crate
(lib name mod_api), generated from the /// docs of the declarations in hooks/*.rs:
cargo doc -p horizon-mod-api --no-deps --open
Each generated Hooks method, event and Host method carries its doc, with the side and cancel contract the
catalog records. CI builds it with -D warnings, so a broken intra-doc link fails. The per-hook prose that predates
the doc field is in Hook notes until it moves into the fragments.
Plain-data source declarations
The bindings generator reads the Rust type declaration; codecs are not maintained separately for native and
wasm mods. A type with a separate public form declares #[horizon_mod_api_macros::wasm(via = Form)] on the
type or its re-export, with TryFrom<&Type> for Form and TryFrom<Form> for Type beside the type.
An owned tuple struct whose fields need load-owned validation declares
#[horizon_mod_api_macros::wasm(host_constructor = from_slot)]. The named associated constructor takes the
fields in declaration order and returns Result<Self, E>, where E implements Display. The macro checks
that signature, and generated incoming host lifts use the constructor and the ordinary typed refusal path.
This applies recursively inside options, lists, records and enum payloads, including hook mutation results.
Lowering is still structural numeric transport. Guest lifts of host-owned values remain structural and do not
need a guest registry table.
BlockKey and EffectKey use this rule to reject unknown incoming slots against their canonical load-owned
name tables. While Hooks::register is in flight, validation reads the active registrar's pending requests;
that same table moves into the installed load state afterward, without a second registry. A valid slot does not
imply that a world's resolved mapping is available: startup, another owning
thread or a refused block reload may have no mapping. Typed operations use trusted numeric indices once that
context exists; key.id(host) -> String remains an explicit diagnostic read through the normal generated backend,
not a per-operation name lookup. The effect registry remains fixed for the process.
Wire boundary coverage
Surface methods excluding unbound and Not on wasm. Generated from both sides' shared surface and current host gap tables.
| Method | Status | Reason |
|---|---|---|
Host::block_entity | crosses | Wire call |
Host::lit_block_name | crosses | Wire call |
Host::fluid_key | crosses | Wire call |
Host::block_state_id | crosses | Wire call |
Host::block_state_string | crosses | Wire call |
Host::block_state_default | crosses | Wire call |
Host::item_crafting_remainder | crosses | Wire call |
Host::block_item | crosses | Wire call |
Host::item_block | crosses | Wire call |
Host::block_collision | crosses | Wire call |
Host::on | crosses | Wire call |
Host::setting | crosses | Wire call |
Host::set_setting | crosses | Wire call |
Host::config_files | crosses | frame accessor; no bytes |
Host::dimension | crosses | Wire call |
Host::entity | crosses | Wire call |
Host::level | crosses | Wire call |
Host::item_entity | crosses | Wire call |
Host::max_stack_size | crosses | Wire call |
Host::max_damage | crosses | Wire call |
Host::item_key | crosses | Wire call |
Host::item_default_component | crosses | Wire call |
Host::item_burn_time | crosses | Wire call |
Host::item_compost_chance | crosses | Wire call |
Host::enchantments_compatible | crosses | Wire call |
Host::entity_type_in_tag | crosses | Wire call |
Host::block_keys | crosses | Wire call |
Host::item_keys | crosses | Wire call |
Host::entity_type_keys | crosses | Wire call |
Host::menu_keys | crosses | Wire call |
Host::enchantment_keys | crosses | Wire call |
Host::is_loaded | crosses | Wire call |
Host::chunk | crosses | Wire call |
Host::living | crosses | Wire call |
Host::mob | crosses | Wire call |
Host::attachment_query | crosses | Wire call |
Host::attachments | crosses | frame accessor; no bytes |
Host::item_cooldown_group | crosses | Wire call |
Host::player | crosses | Wire call |
Host::mod_recipe_property_contains | crosses | Wire call |
Host::recipes_of_type | crosses | Wire call |
Host::recipe_ingredient | crosses | Wire call |
Host::recipe_result | crosses | Wire call |
Host::known_recipes | crosses | Wire call |
Host::recipe_book_category | crosses | Wire call |
Host::resource | crosses | Wire call |
Host::unresolved_fluid_id | crosses | Wire call |
Host::block_in_tag | crosses | Wire call |
Host::state_outline | crosses | Wire call |
Host::state_fluid_state | crosses | Wire call |
Host::fluid_in_tag | crosses | Wire call |
Host::fluid_is_same | crosses | Wire call |
Host::fluid_bucket | crosses | Wire call |
Host::fluid_pickup_sound | crosses | Wire call |
ServerHost::block_entity_sync | crosses | Wire call |
ServerHost::set_block_property | crosses | Wire call |
ServerHost::set_block_property_with | crosses | Wire call |
ServerHost::boss_bar | crosses | Wire call |
ServerHost::boss_bars | crosses | Wire call |
ServerHost::create_boss_bar | crosses | Wire call |
ServerHost::tick_stats | crosses | Wire call |
ServerHost::set_tick_rate | crosses | Wire call |
ServerHost::reload_resources | crosses | frame accessor; no bytes |
ServerHost::display | crosses | Wire call |
ServerHost::summon | crosses | Wire call |
ServerHost::set_experience_orb | crosses | Wire call |
ServerHost::experience_orbs | crosses | Wire call |
ServerHost::spawn_falling_block | crosses | Wire call |
ServerHost::dispense_default | crosses | Wire call |
ServerHost::open_menu | crosses | Wire call |
ServerHost::close_menu | crosses | Wire call |
ServerHost::merchant | crosses | Wire call |
ServerHost::minecart | crosses | Wire call |
ServerHost::kill_mob | crosses | Wire call |
ServerHost::dragon | crosses | Wire call |
ServerHost::attachment_validate_write | crosses | Wire call |
ServerHost::transaction_open | crosses | Wire call |
ServerHost::transaction_close | crosses | Wire call |
ServerHost::schedule_task | crosses | Wire call |
ServerHost::cancel_task | crosses | Wire call |
ServerHost::net_send_to_player | crosses | Wire call |
ServerHost::fake_player | crosses | Wire call |
ServerHost::spawn_projectile_from_rotation | crosses | Wire call |
ServerHost::projectile | crosses | Wire call |
ServerHost::fishing_hook | crosses | Wire call |
ServerHost::edit_blocks | crosses | Wire call |
ServerHost::surface_heights | crosses | Wire call |
ServerHost::read_columns | crosses | Wire call |
ServerHost::structure_template | crosses | Wire call |
ClientHost::claim_items | crosses | Wire call |
ClientHost::poll | crosses | Wire call |
ClientHost::cursor | crosses | Wire call |
ClientHost::mod_key | crosses | Wire call |
ClientHost::playing | crosses | Wire call |
ClientHost::mapping | crosses | Wire call |
ClientHost::millis | crosses | Wire call |
ClientHost::modifiers | crosses | Wire call |
ClientHost::clipboard_text | crosses | Wire call |
ClientHost::set_clipboard_text | crosses | Wire call |
ClientHost::input_options | crosses | Wire call |
ClientHost::graphics_options | crosses | Wire call |
ClientHost::gui_options | crosses | Wire call |
ClientHost::chat_options | crosses | Wire call |
ClientHost::audio_options | crosses | Wire call |
ClientHost::instance_group_write | crosses | Wire call |
ClientHost::instance_group_set_len | crosses | Wire call |
ClientHost::instance_group_set_transform | crosses | Wire call |
ClientHost::instance_group_destroy | crosses | Wire call |
ClientHost::refresh_render_visibility | crosses | Wire call |
ClientHost::refresh_biome_colors | crosses | Wire call |
ClientHost::mod_image | crosses | Wire call |
ClientHost::block_face_sprite | crosses | Wire call |
ClientHost::block_sprites | crosses | Wire call |
ClientHost::texture_sprites | crosses | Wire call |
ClientHost::gui_sprite | crosses | Wire call |
ClientHost::block_sprites_version | crosses | Wire call |
ClientHost::text_string | crosses | Wire call |
ClientHost::text_visual_order | crosses | Wire call |
ClientHost::text_width | crosses | Wire call |
ClientHost::text_split | crosses | Wire call |
ClientHost::hud_mode | crosses | Wire call |
ClientHost::set_hud_mode | crosses | Wire call |
ClientHost::camera_type | crosses | Wire call |
ClientHost::set_camera_type | crosses | Wire call |
ClientHost::is_paused | crosses | Wire call |
ClientHost::clock | crosses | Wire call |
ClientHost::raycast | crosses | Wire call |
ClientHost::view_hit | crosses | Wire call |
ClientHost::camera | crosses | Wire call |
ClientHost::camera_entity | crosses | Wire call |
ClientHost::client_surface_heights | crosses | Wire call |
ClientHost::set_camera_entity | crosses | Wire call |
ClientHost::crosshair_target | crosses | Wire call |
ClientHost::targeting | crosses | Wire call |
ClientHost::set_targeting | crosses | Wire call |
ClientHost::translate | crosses | Wire call |
ClientHost::setting_bool | crosses | Wire call |
ClientHost::setting_choice | crosses | Wire call |
ClientHost::setting_f64 | crosses | Wire call |
ClientHost::setting_color | crosses | Wire call |
ClientHost::fluid_name | crosses | Wire call |
ClientHost::bake_layer | crosses | Wire call |
ClientHost::muffle_sounds | crosses | Wire call |
ClientHost::flame_particle | crosses | Wire call |
ClientHost::play_local_sound | crosses | Wire call |
ClientHost::particle_count | crosses | Wire call |
ClientHost::clear_particles | crosses | Wire call |
ClientHost::add_particle | crosses | Wire call |
ClientHost::sound_events | crosses | Wire call |
ClientHost::audio_devices | crosses | Wire call |
ClientHost::audio_device | crosses | Wire call |
ClientHost::item_particle_options | crosses | Wire call |
ClientHost::give | crosses | Wire call |
ClientHost::enchantments | crosses | Wire call |
ClientHost::enchantment_min_cost | crosses | Wire call |
ClientHost::enchantment_supports | crosses | Wire call |
ClientHost::local_player | crosses | Wire call |
ClientHost::open_chat | crosses | Wire call |
ClientHost::net_send_to_server | crosses | Wire call |
ClientHost::chat_line | crosses | Wire call |
ClientHost::send_chat | crosses | Wire call |
ClientHost::send_packet | crosses | Wire call |
ClientHost::push_player | crosses | Wire call |
ClientHost::panel_items | crosses | Wire call |
ClientHost::place_recipe | crosses | Wire call |
ClientHost::transfer_recipe | crosses | Wire call |
ClientHost::preset_editor | crosses | Wire call |
ClientHost::preset_editor_dimensions | crosses | Wire call |
ClientHost::set_preset_editor_dimensions | crosses | Wire call |
ClientHost::screen_pos | crosses | Wire call |
ClientHost::screen_exclusion | crosses | Wire call |
ClientHost::screen_hovered_item | crosses | Wire call |
ClientHost::apply_screen_blur | crosses | Wire call |
ClientHost::menu_blur_radius | crosses | Wire call |
ClientHost::mod_screen_open | crosses | Wire call |
ClientHost::mod_screen_close | crosses | Wire call |
ClientHost::mod_screen | crosses | Wire call |
ClientHost::screen_title | crosses | Wire call |
ClientHost::window_focused | crosses | Wire call |
ClientHost::observed_screen_id | crosses | Wire call |
ClientHost::is_key_down | crosses | Wire call |
Backend::block_entity_kind | crosses | Wire call |
Backend::block_entity_position | crosses | Wire call |
Backend::block_entity_level | crosses | Wire call |
Backend::block_entity_data | crosses | Wire call |
Backend::block_entity_lock | crosses | Wire call |
Backend::block_entity_name | crosses | Wire call |
Backend::block_entity_viewers | crosses | Wire call |
Backend::block_entity_data_slots | crosses | Wire call |
Backend::block_entity_item_slot | crosses | Wire call |
Backend::block_metadata_name | crosses | Wire call |
Backend::block_metadata_property | crosses | Wire call |
Backend::block_metadata_with | crosses | Wire call |
Backend::block_metadata_of | crosses | Wire call |
Backend::block_state_replaceable | crosses | Wire call |
Backend::block_state_destroy_speed | crosses | Wire call |
Backend::block_state_sound | crosses | Wire call |
Backend::block_state_has_block_entity | crosses | Wire call |
Backend::block_state_map_color | crosses | Wire call |
Backend::block_states_of | crosses | Wire call |
Backend::block_state_definition | crosses | Wire call |
Backend::block_state_matches | crosses | Wire call |
Backend::fluid_state_definition | crosses | Wire call |
Backend::fluid_type_properties | crosses | Wire call |
Backend::fluid_legacy_block | crosses | Wire call |
Backend::fluid_state_validate | crosses | Wire call |
Backend::boss_bar_id | crosses | Wire call |
Backend::boss_bar_name | crosses | Wire call |
Backend::boss_bar_color | crosses | Wire call |
Backend::boss_bar_overlay | crosses | Wire call |
Backend::boss_bar_darken_screen | crosses | Wire call |
Backend::boss_bar_play_boss_music | crosses | Wire call |
Backend::boss_bar_create_world_fog | crosses | Wire call |
Backend::boss_bar_value | crosses | Wire call |
Backend::boss_bar_max | crosses | Wire call |
Backend::boss_bar_visible | crosses | Wire call |
Backend::boss_bar_players | crosses | Wire call |
Backend::item_data_map_value | crosses | Wire call |
Backend::block_data_map_value | crosses | Wire call |
Backend::display_kind | crosses | Wire call |
Backend::display_transformation | crosses | Wire call |
Backend::display_billboard | crosses | Wire call |
Backend::display_brightness | crosses | Wire call |
Backend::display_view_range | crosses | Wire call |
Backend::display_shadow_radius | crosses | Wire call |
Backend::display_shadow_strength | crosses | Wire call |
Backend::display_width | crosses | Wire call |
Backend::display_height | crosses | Wire call |
Backend::display_glow_color_override | crosses | Wire call |
Backend::display_interpolation_duration | crosses | Wire call |
Backend::display_start_interpolation | crosses | Wire call |
Backend::display_teleport_duration | crosses | Wire call |
Backend::display_text | crosses | Wire call |
Backend::display_line_width | crosses | Wire call |
Backend::display_background | crosses | Wire call |
Backend::display_text_opacity | crosses | Wire call |
Backend::display_text_shadow | crosses | Wire call |
Backend::display_see_through | crosses | Wire call |
Backend::display_default_background | crosses | Wire call |
Backend::display_alignment | crosses | Wire call |
Backend::display_block_state | crosses | Wire call |
Backend::display_item | crosses | Wire call |
Backend::display_item_context | crosses | Wire call |
Backend::sound_state | crosses | Wire call |
Backend::sound_stop | crosses | Wire call |
Backend::sound_fade | crosses | Wire call |
Backend::entity_tags | crosses | Wire call |
Backend::entity_name | crosses | Wire call |
Backend::entity_custom_name_visible | crosses | Wire call |
Backend::entity_classification | crosses | Wire call |
Backend::entity_added_to_level | crosses | Wire call |
Backend::entity_position | crosses | Wire call |
Backend::entity_block_position | crosses | Wire call |
Backend::entity_rotation | crosses | Wire call |
Backend::entity_kind | crosses | Wire call |
Backend::entity_level | crosses | Wire call |
Backend::entity_permanently_invulnerable | crosses | Wire call |
Backend::entity_invulnerable_time | crosses | Wire call |
Backend::entity_damage_cooldown_time | crosses | Wire call |
Backend::entity_invulnerable_to_damage | crosses | Wire call |
Backend::entity_entities_team_damage_allowed | crosses | Wire call |
Backend::entity_invulnerable_to_piercing_weapon | crosses | Wire call |
Backend::entity_velocity | crosses | Wire call |
Backend::entity_on_ground | crosses | Wire call |
Backend::entity_bounding_box | crosses | Wire call |
Backend::entity_eye_height | crosses | Wire call |
Backend::entity_eye_position | crosses | Wire call |
Backend::entity_view_vector | crosses | Wire call |
Backend::entity_up_vector | crosses | Wire call |
Backend::entity_on_block | crosses | Wire call |
Backend::entity_pick | crosses | Wire call |
Backend::entity_is_multipart | crosses | Wire call |
Backend::entity_parts | crosses | Wire call |
Backend::entity_distance_to_sqr | crosses | Wire call |
Backend::entity_head_yaw | crosses | Wire call |
Backend::entity_direction | crosses | Wire call |
Backend::entity_dimensions | crosses | Wire call |
Backend::entity_no_physics | crosses | Wire call |
Backend::entity_vehicle | crosses | Wire call |
Backend::entity_is_passenger | crosses | Wire call |
Backend::entity_root_vehicle | crosses | Wire call |
Backend::entity_passengers | crosses | Wire call |
Backend::entity_is_vehicle | crosses | Wire call |
Backend::entity_indirect_passengers | crosses | Wire call |
Backend::entity_controlling_passenger | crosses | Wire call |
Backend::entity_is_passenger_of_same_vehicle | crosses | Wire call |
Backend::entity_dismount_location | crosses | Wire call |
Backend::entity_leash_holder | crosses | Wire call |
Backend::entity_sound_source | crosses | Wire call |
Backend::entity_spectator | crosses | Wire call |
Backend::entity_is_shift_key_down | crosses | Wire call |
Backend::entity_is_crouching | crosses | Wire call |
Backend::entity_is_sprinting | crosses | Wire call |
Backend::entity_is_in_water | crosses | Wire call |
Backend::entity_is_in_lava | crosses | Wire call |
Backend::entity_fire_immune | crosses | Wire call |
Backend::entity_fire_ticks | crosses | Wire call |
Backend::entity_portal_cooldown | crosses | Wire call |
Backend::entity_air_supply | crosses | Wire call |
Backend::entity_max_air_supply | crosses | Wire call |
Backend::entity_is_silent | crosses | Wire call |
Backend::entity_glowing | crosses | Wire call |
Backend::entity_no_gravity | crosses | Wire call |
Backend::entity_ticks_frozen | crosses | Wire call |
Backend::entity_tick_count | crosses | Wire call |
Backend::entity_is_invisible | crosses | Wire call |
Backend::entity_pose | crosses | Wire call |
Backend::energy_amount | crosses | Wire call |
Backend::energy_capacity | crosses | Wire call |
Backend::fluid_tank_count | crosses | Wire call |
Backend::fluid_tank | crosses | Wire call |
Backend::fluid_tank_capacity | crosses | Wire call |
Backend::item_entity_stack | crosses | Wire call |
Backend::item_entity_pickup_delay | crosses | Wire call |
Backend::item_entity_age | crosses | Wire call |
Backend::item_entity_target | crosses | Wire call |
Backend::item_entity_owner | crosses | Wire call |
Backend::item_resource_damageable | crosses | Wire call |
Backend::item_resource_foil | crosses | Wire call |
Backend::item_resource_repairable | crosses | Wire call |
Backend::item_resource_use_duration | crosses | Wire call |
Backend::item_resource_bar | crosses | Wire call |
Backend::item_resource_entity_attack_range | crosses | Wire call |
Backend::item_resource_damage_source | crosses | Wire call |
Backend::item_resource_can_be_hurt_by | crosses | Wire call |
Backend::item_resource_attack_damage_bonus | crosses | Wire call |
Backend::item_resource_tooltip | crosses | Wire call |
Backend::container_size | crosses | Wire call |
Backend::container_stack | crosses | Wire call |
Backend::container_capacity | crosses | Wire call |
Backend::item_has_enchantment_effect | crosses | Wire call |
Backend::item_equippable_slot | crosses | Wire call |
Backend::item_container_slots | crosses | Wire call |
Backend::item_container_insert | crosses | Wire call |
Backend::item_container_extract | crosses | Wire call |
Backend::item_container_insert_slot | crosses | Wire call |
Backend::item_container_capacity | crosses | Wire call |
Backend::entity_container_size | crosses | Wire call |
Backend::entity_automation_size | crosses | Wire call |
Backend::entity_equipment_size | crosses | Wire call |
Backend::block_key_id | crosses | Wire call |
Backend::item_key_id | crosses | Wire call |
Backend::effect_key_id | crosses | Wire call |
Backend::effect_category | crosses | Wire call |
Backend::fluid_key_id | crosses | Wire call |
Backend::block_key | crosses | Wire call |
Backend::effect_key | crosses | Wire call |
Backend::block_entity_type_key_id | crosses | Wire call |
Backend::entity_type_key_id | crosses | Wire call |
Backend::menu_key_id | crosses | Wire call |
Backend::enchantment_key_id | crosses | Wire call |
Backend::level_find_closest_biome | crosses | Wire call |
Backend::level_find_nearest_structure | crosses | Wire call |
Backend::level_get_sky_darken | crosses | Wire call |
Backend::level_block_floor_height | crosses | Wire call |
Backend::level_biome_at | crosses | Wire call |
Backend::level_datapack_entries | crosses | Wire call |
Backend::level_datapack_handle | crosses | Wire call |
Backend::level_datapack_read | crosses | Wire call |
Backend::level_block_break_speed | crosses | Wire call |
Backend::level_client_biome | crosses | Wire call |
Backend::level_biome_spawners | crosses | Wire call |
Backend::level_biome_effects | crosses | Wire call |
Backend::level_biome_in_tag | crosses | Wire call |
Backend::level_biome_properties | crosses | Wire call |
Backend::level_logical_height | crosses | Wire call |
Backend::level_world_border | crosses | Wire call |
Backend::dimension_key_id | crosses | Wire call |
Backend::level_chunk_ref | crosses | Wire call |
Backend::chunk_forced | crosses | Wire call |
Backend::level_forced_chunks | crosses | Wire call |
Backend::level_mod_forced_chunks | crosses | Wire call |
Backend::chunk_loaded | crosses | Wire call |
Backend::chunk_status | crosses | Wire call |
Backend::chunk_watchers | crosses | Wire call |
Backend::level_recipe_for | crosses | Wire call |
Backend::level_all_entities | crosses | Wire call |
Backend::level_entities_in | crosses | Wire call |
Backend::level_online_players | crosses | Wire call |
Backend::level_block_state | crosses | Wire call |
Backend::level_block_transform | crosses | Wire call |
Backend::level_block | crosses | Wire call |
Backend::level_block_property | crosses | Wire call |
Backend::level_in_place | crosses | Wire call |
Backend::level_is_loaded | crosses | Wire call |
Backend::level_brightness | crosses | Wire call |
Backend::level_height | crosses | Wire call |
Backend::level_in_world_bounds | crosses | Wire call |
Backend::level_in_spawnable_bounds | crosses | Wire call |
Backend::level_game_time | crosses | Wire call |
Backend::level_day_time | crosses | Wire call |
Backend::level_clock_time | crosses | Wire call |
Backend::level_fluid_flow | crosses | Wire call |
Backend::level_block_shape | crosses | Wire call |
Backend::level_clip | crosses | Wire call |
Backend::level_block_face_sturdy | crosses | Wire call |
Backend::level_signal | crosses | Wire call |
Backend::level_direct_signal | crosses | Wire call |
Backend::level_direct_signal_to | crosses | Wire call |
Backend::level_best_neighbor_signal | crosses | Wire call |
Backend::level_has_neighbor_signal | crosses | Wire call |
Backend::level_dimension_shape | crosses | Wire call |
Backend::level_dimension_type | crosses | Wire call |
Backend::level_sea_level | crosses | Wire call |
Backend::level_seed | crosses | Wire call |
Backend::level_difficulty | crosses | Wire call |
Backend::level_local_difficulty | crosses | Wire call |
Backend::level_game_rule_bool | crosses | Wire call |
Backend::level_game_rule | crosses | Wire call |
Backend::level_game_rules | crosses | Wire call |
Backend::level_weather | crosses | Wire call |
Backend::level_is_raining | crosses | Wire call |
Backend::level_is_raining_at | crosses | Wire call |
Backend::level_enabled_features | crosses | Wire call |
Backend::level_is_thundering | crosses | Wire call |
Backend::level_random | crosses | Wire call |
Backend::level_raid | crosses | Wire call |
Backend::level_raid_at | crosses | Wire call |
Backend::level_raids | crosses | Wire call |
Backend::level_fluid_state | crosses | Wire call |
Backend::level_loaded_chunks | crosses | Wire call |
Backend::level_collision_boxes | crosses | Wire call |
Backend::level_respawn_data | crosses | Wire call |
Backend::level_map_data | crosses | Wire call |
Backend::level_can_see_sky | crosses | Wire call |
Backend::level_line_of_sight | crosses | Wire call |
Backend::level_has_collisions | crosses | Wire call |
Backend::level_fluid_shape | crosses | Wire call |
Backend::level_fluid_tick_delay | crosses | Wire call |
Backend::level_fluid_height | crosses | Wire call |
Backend::level_fluid_height_for_camera | crosses | Wire call |
Backend::level_fluid_can_be_replaced_with | crosses | Wire call |
Backend::level_ray_trace_entities | crosses | Wire call |
Backend::level_spawn_limits | crosses | Wire call |
Backend::level_spawn_flags | crosses | Wire call |
Backend::level_can_spawn_entities_in_chunk | crosses | Wire call |
Backend::living_effect | crosses | Wire call |
Backend::living_effects | crosses | Wire call |
Backend::living_potion_affected | crosses | Wire call |
Backend::living_gliding_ticks | crosses | Wire call |
Backend::living_is_gliding | crosses | Wire call |
Backend::living_is_sleeping | crosses | Wire call |
Backend::living_sleeping_pos | crosses | Wire call |
Backend::living_is_jumping | crosses | Wire call |
Backend::living_is_climbing | crosses | Wire call |
Backend::living_baby | crosses | Wire call |
Backend::living_health | crosses | Wire call |
Backend::living_max_health | crosses | Wire call |
Backend::living_alive | crosses | Wire call |
Backend::living_kill_credit | crosses | Wire call |
Backend::living_last_attacker | crosses | Wire call |
Backend::living_has_line_of_sight | crosses | Wire call |
Backend::living_attribute_value | crosses | Wire call |
Backend::living_attribute_base | crosses | Wire call |
Backend::living_attributes | crosses | Wire call |
Backend::living_attribute_modifiers | crosses | Wire call |
Backend::living_equipment | crosses | Wire call |
Backend::living_hand_item | crosses | Wire call |
Backend::living_is_tamed | crosses | Wire call |
Backend::living_owner | crosses | Wire call |
Backend::living_item_use_remaining | crosses | Wire call |
Backend::living_used_item_hand | crosses | Wire call |
Backend::living_is_blocking | crosses | Wire call |
Backend::living_dimensions_for_pose | crosses | Wire call |
Backend::living_can_use_portal | crosses | Wire call |
Backend::living_in_melee_range | crosses | Wire call |
Backend::living_can_freeze | crosses | Wire call |
Backend::living_can_breathe_underwater | crosses | Wire call |
Backend::living_bed_orientation | crosses | Wire call |
Backend::living_effect_blend_factor | crosses | Wire call |
Backend::menu_container_id | crosses | Wire call |
Backend::menu_kind | crosses | Wire call |
Backend::menu_slot_count | crosses | Wire call |
Backend::menu_slot | crosses | Wire call |
Backend::menu_carried | crosses | Wire call |
Backend::menu_slot_backing | crosses | Wire call |
Backend::menu_slot_holds | crosses | Wire call |
Backend::menu_slot_gives | crosses | Wire call |
Backend::menu_slot_boxes | crosses | Wire call |
Backend::merchant_offers | crosses | Wire call |
Backend::merchant_trading_player | crosses | Wire call |
Backend::merchant_profession | crosses | Wire call |
Backend::merchant_level | crosses | Wire call |
Backend::merchant_villager_type | crosses | Wire call |
Backend::merchant_xp | crosses | Wire call |
Backend::minecart_push | crosses | Wire call |
Backend::minecart_kind | crosses | Wire call |
Backend::minecart_max_speed | crosses | Wire call |
Backend::minecart_is_on_rails | crosses | Wire call |
Backend::minecart_flipped | crosses | Wire call |
Backend::minecart_damage | crosses | Wire call |
Backend::minecart_hurt_time | crosses | Wire call |
Backend::minecart_custom_display_block | crosses | Wire call |
Backend::minecart_display_offset | crosses | Wire call |
Backend::minecart_fuel | crosses | Wire call |
Backend::minecart_fuse | crosses | Wire call |
Backend::minecart_hopper_enabled | crosses | Wire call |
Backend::minecart_command | crosses | Wire call |
Backend::dragon_phase | crosses | Wire call |
Backend::dragon_alive_crystals | crosses | Wire call |
Backend::dragon_previously_killed | crosses | Wire call |
Backend::mob_goals | crosses | Wire call |
Backend::mob_target_goals | crosses | Wire call |
Backend::mob_is_done | crosses | Wire call |
Backend::mob_navigation_speed | crosses | Wire call |
Backend::mob_pathfinding_malus | crosses | Wire call |
Backend::mob_can_use_portal | crosses | Wire call |
Backend::mob_target | crosses | Wire call |
Backend::mob_is_aggressive | crosses | Wire call |
Backend::mob_persistence_required | crosses | Wire call |
Backend::mob_requires_custom_persistence | crosses | Wire call |
Backend::mob_no_ai | crosses | Wire call |
Backend::mob_restrict_center | crosses | Wire call |
Backend::mob_restrict_radius | crosses | Wire call |
Backend::mob_has_restriction | crosses | Wire call |
Backend::mob_is_within_restriction | crosses | Wire call |
Backend::mob_left_handed | crosses | Wire call |
Backend::mob_can_pick_up_loot | crosses | Wire call |
Backend::mob_no_action_time | crosses | Wire call |
Backend::mob_raid | crosses | Wire call |
Backend::unspawned_mob_kind | crosses | Wire call |
Backend::unspawned_mob_position | crosses | Wire call |
Backend::unspawned_mob_equipment | crosses | Wire call |
Backend::unspawned_mob_attribute_base | crosses | Wire call |
Backend::unspawned_mob_attribute_value | crosses | Wire call |
Backend::unspawned_mob_tags | crosses | Wire call |
Backend::unspawned_mob_custom_name | crosses | Wire call |
Backend::player_recipe_book_settings | crosses | Wire call |
Backend::player_address | crosses | Wire call |
Backend::player_name | crosses | Wire call |
Backend::player_level | crosses | Wire call |
Backend::player_dimension | crosses | Wire call |
Backend::player_selected_slot | crosses | Wire call |
Backend::player_container_menu | crosses | Wire call |
Backend::player_item_cooldowns | crosses | Wire call |
Backend::player_has_item_cooldown | crosses | Wire call |
Backend::player_item_cooldown_percent | crosses | Wire call |
Backend::player_attack_strength_scale | crosses | Wire call |
Backend::player_exp_cooldown | crosses | Wire call |
Backend::player_last_death_location | crosses | Wire call |
Backend::player_needs_food | crosses | Wire call |
Backend::player_has_enough_food | crosses | Wire call |
Backend::player_can_eat | crosses | Wire call |
Backend::player_experience | crosses | Wire call |
Backend::player_xp_needed_for_next_level | crosses | Wire call |
Backend::player_permission_level | crosses | Wire call |
Backend::player_has_infinite_materials | crosses | Wire call |
Backend::player_can_use_game_master_blocks | crosses | Wire call |
Backend::player_previous_game_mode | crosses | Wire call |
Backend::player_is_secondary_use_active | crosses | Wire call |
Backend::player_is_fake | crosses | Wire call |
Backend::player_may_use_item_at | crosses | Wire call |
Backend::player_can_harm | crosses | Wire call |
Backend::player_respawn_config | crosses | Wire call |
Backend::player_text_filtering | crosses | Wire call |
Backend::player_sleep_ignored | crosses | Wire call |
Backend::player_counts_for_sleep | crosses | Wire call |
Backend::player_sleep_timer | crosses | Wire call |
Backend::player_inventory | crosses | Wire call |
Backend::player_game_mode | crosses | Wire call |
Backend::player_is_creative | crosses | Wire call |
Backend::player_is_spectator | crosses | Wire call |
Backend::player_may_build | crosses | Wire call |
Backend::player_food | crosses | Wire call |
Backend::player_abilities | crosses | Wire call |
Backend::player_stat | crosses | Wire call |
Backend::player_has_discovered_recipe | crosses | Wire call |
Backend::player_discovered_recipes | crosses | Wire call |
Backend::player_client_brand | crosses | Wire call |
Backend::player_synced_recipe_book_settings | crosses | Wire call |
Backend::player_block_breaking | crosses | Wire call |
Backend::player_ambient_mood | crosses | Wire call |
Backend::projectile_owner | crosses | Wire call |
Backend::projectile_pickup | crosses | Wire call |
Backend::projectile_pierce_level | crosses | Wire call |
Backend::projectile_base_damage | crosses | Wire call |
Backend::projectile_critical | crosses | Wire call |
Backend::fishing_hook_player_owner | crosses | Wire call |
Backend::fishing_hook_hooked_in | crosses | Wire call |
Backend::fishing_hook_open_water | crosses | Wire call |
Backend::raid_center | crosses | Wire call |
Backend::raid_omen_level | crosses | Wire call |
Backend::raid_groups_spawned | crosses | Wire call |
Backend::raid_num_groups | crosses | Wire call |
Backend::raid_has_bonus_wave | crosses | Wire call |
Backend::raid_is_started | crosses | Wire call |
Backend::raid_is_active | crosses | Wire call |
Backend::raid_status | crosses | Wire call |
Backend::raid_total_health | crosses | Wire call |
Backend::raid_living_health | crosses | Wire call |
Backend::raid_bar_progress | crosses | Wire call |
Backend::raid_raiders | crosses | Wire call |
Backend::raid_wave_raiders | crosses | Wire call |
Backend::raid_heroes | crosses | Wire call |
Backend::recipe_ingredient_item_keys | crosses | Wire call |
Backend::recipe_ingredient_test | crosses | Wire call |
BackendMut::block_entity_set_lock | crosses | Wire call |
BackendMut::block_entity_merge_data | crosses | Wire call |
BackendMut::block_entity_replace_data | crosses | Wire call |
BackendMut::block_entity_set_changed | crosses | Wire call |
BackendMut::block_entity_set_name | crosses | Wire call |
BackendMut::block_entity_set_data_slot | crosses | Wire call |
BackendMut::block_entity_set_data_slots | crosses | Wire call |
BackendMut::block_entity_set_item_slot | crosses | Wire call |
BackendMut::boss_bar_set_name | crosses | Wire call |
BackendMut::boss_bar_set_color | crosses | Wire call |
BackendMut::boss_bar_set_overlay | crosses | Wire call |
BackendMut::boss_bar_set_darken_screen | crosses | Wire call |
BackendMut::boss_bar_set_play_boss_music | crosses | Wire call |
BackendMut::boss_bar_set_create_world_fog | crosses | Wire call |
BackendMut::boss_bar_set_value | crosses | Wire call |
BackendMut::boss_bar_set_max | crosses | Wire call |
BackendMut::boss_bar_set_visible | crosses | Wire call |
BackendMut::boss_bar_add_player | crosses | Wire call |
BackendMut::boss_bar_remove_player | crosses | Wire call |
BackendMut::boss_bar_remove | crosses | Wire call |
BackendMut::display_set_transformation | crosses | Wire call |
BackendMut::display_set_billboard | crosses | Wire call |
BackendMut::display_set_brightness | crosses | Wire call |
BackendMut::display_set_view_range | crosses | Wire call |
BackendMut::display_set_shadow_radius | crosses | Wire call |
BackendMut::display_set_shadow_strength | crosses | Wire call |
BackendMut::display_set_width | crosses | Wire call |
BackendMut::display_set_height | crosses | Wire call |
BackendMut::display_set_glow_color_override | crosses | Wire call |
BackendMut::display_set_interpolation_duration | crosses | Wire call |
BackendMut::display_set_start_interpolation | crosses | Wire call |
BackendMut::display_set_teleport_duration | crosses | Wire call |
BackendMut::display_set_text | crosses | Wire call |
BackendMut::display_set_line_width | crosses | Wire call |
BackendMut::display_set_background | crosses | Wire call |
BackendMut::display_set_text_opacity | crosses | Wire call |
BackendMut::display_set_text_shadow | crosses | Wire call |
BackendMut::display_set_see_through | crosses | Wire call |
BackendMut::display_set_default_background | crosses | Wire call |
BackendMut::display_set_alignment | crosses | Wire call |
BackendMut::display_set_block_state | crosses | Wire call |
BackendMut::display_set_item | crosses | Wire call |
BackendMut::display_set_item_context | crosses | Wire call |
BackendMut::entity_nbt | crosses | Wire call |
BackendMut::entity_merge_nbt | crosses | Wire call |
BackendMut::entity_add_tag | crosses | Wire call |
BackendMut::entity_remove_tag | crosses | Wire call |
BackendMut::entity_set_custom_name | crosses | Wire call |
BackendMut::entity_set_custom_name_visible | crosses | Wire call |
BackendMut::entity_reset_fall_distance | crosses | Wire call |
BackendMut::entity_uuid | crosses | Wire call |
BackendMut::entity_set_invulnerable | crosses | Wire call |
BackendMut::entity_set_damage_cooldown_time | crosses | Wire call |
BackendMut::entity_set_invulnerable_time | crosses | Wire call |
BackendMut::entity_hurt | crosses | Wire call |
BackendMut::entity_set_velocity | crosses | Wire call |
BackendMut::entity_teleport_to | crosses | Wire call |
BackendMut::entity_teleport_to_level | crosses | Wire call |
BackendMut::entity_set_rotation | crosses | Wire call |
BackendMut::entity_set_head_yaw | crosses | Wire call |
BackendMut::entity_push | crosses | Wire call |
BackendMut::entity_discard | crosses | Wire call |
BackendMut::entity_remove | crosses | Wire call |
BackendMut::entity_spawn_at_location | crosses | Wire call |
BackendMut::entity_start_riding | crosses | Wire call |
BackendMut::entity_stop_riding | crosses | Wire call |
BackendMut::entity_eject_passengers | crosses | Wire call |
BackendMut::entity_leash_to | crosses | Wire call |
BackendMut::entity_leash_to_knot | crosses | Wire call |
BackendMut::entity_detach_leash | crosses | Wire call |
BackendMut::entity_play_sound | crosses | Wire call |
BackendMut::entity_game_event | crosses | Wire call |
BackendMut::entity_broadcast_event | crosses | Wire call |
BackendMut::entity_set_remaining_fire_ticks | crosses | Wire call |
BackendMut::entity_clear_fire | crosses | Wire call |
BackendMut::entity_extinguish_fire | crosses | Wire call |
BackendMut::entity_set_air_supply | crosses | Wire call |
BackendMut::entity_set_silent | crosses | Wire call |
BackendMut::entity_set_glowing | crosses | Wire call |
BackendMut::entity_set_no_gravity | crosses | Wire call |
BackendMut::entity_set_ticks_frozen | crosses | Wire call |
BackendMut::entity_set_invisible | crosses | Wire call |
BackendMut::entity_play_bound_sound | crosses | Wire call |
BackendMut::energy_insert | crosses | Wire call |
BackendMut::energy_extract | crosses | Wire call |
BackendMut::fluid_tank_insert | crosses | Wire call |
BackendMut::fluid_tank_extract | crosses | Wire call |
BackendMut::item_entity_set_stack | crosses | Wire call |
BackendMut::item_entity_set_pickup_delay | crosses | Wire call |
BackendMut::item_entity_set_default_pickup_delay | crosses | Wire call |
BackendMut::item_entity_set_no_pickup_delay | crosses | Wire call |
BackendMut::item_entity_set_never_pickup | crosses | Wire call |
BackendMut::item_entity_set_target | crosses | Wire call |
BackendMut::container_set_stack_limit | crosses | Wire call |
BackendMut::item_resource_enchant_randomly | crosses | Wire call |
BackendMut::container_insert | crosses | Wire call |
BackendMut::container_extract | crosses | Wire call |
BackendMut::game_stack | crosses | Wire call |
BackendMut::game_insert | crosses | Wire call |
BackendMut::game_extract | crosses | Wire call |
BackendMut::drop_at | crosses | Wire call |
BackendMut::level_spawn_mob | crosses | Wire call |
BackendMut::level_set_spawn_limits | crosses | Wire call |
BackendMut::level_set_spawn_flags | crosses | Wire call |
BackendMut::chunk_set_forced | crosses | Wire call |
BackendMut::chunk_set_mod_forced | crosses | Wire call |
BackendMut::level_set_block | crosses | Wire call |
BackendMut::level_remove_block | crosses | Wire call |
BackendMut::level_destroy_block | crosses | Wire call |
BackendMut::level_set_respawn_data | crosses | Wire call |
BackendMut::level_set_map_data | crosses | Wire call |
BackendMut::level_block_tick | crosses | Wire call |
BackendMut::level_request_save | crosses | Wire call |
BackendMut::level_set_world_border_center | crosses | Wire call |
BackendMut::level_set_world_border_size | crosses | Wire call |
BackendMut::level_set_world_border_damage | crosses | Wire call |
BackendMut::level_set_world_border_warning | crosses | Wire call |
BackendMut::level_set_weather | crosses | Wire call |
BackendMut::level_set_weather_levels | crosses | Wire call |
BackendMut::level_block_random_tick | crosses | Wire call |
BackendMut::level_fluid_tick | crosses | Wire call |
BackendMut::level_lightning_target | crosses | Wire call |
BackendMut::level_block_drops | crosses | Wire call |
BackendMut::level_roll_loot | crosses | Wire call |
BackendMut::level_set_break_progress | crosses | Wire call |
BackendMut::level_send_block_updated | crosses | Wire call |
BackendMut::level_play_sound | crosses | Wire call |
BackendMut::level_send_particles | crosses | Wire call |
BackendMut::level_level_event | crosses | Wire call |
BackendMut::level_game_event | crosses | Wire call |
BackendMut::level_block_event | crosses | Wire call |
BackendMut::level_explode | crosses | Wire call |
BackendMut::level_strike_lightning | crosses | Wire call |
BackendMut::level_pop_resource | crosses | Wire call |
BackendMut::level_spawn_item | crosses | Wire call |
BackendMut::level_spawn_item_with_motion | crosses | Wire call |
BackendMut::level_award_experience | crosses | Wire call |
BackendMut::level_update_neighbors | crosses | Wire call |
BackendMut::level_neighbor_changed | crosses | Wire call |
BackendMut::level_set_random | crosses | Wire call |
BackendMut::level_bone_meal | crosses | Wire call |
BackendMut::level_set_biome | crosses | Wire call |
BackendMut::living_apply_consume_effect | crosses | Wire call |
BackendMut::living_add_effect | crosses | Wire call |
BackendMut::living_remove_effect | crosses | Wire call |
BackendMut::living_remove_all_effects | crosses | Wire call |
BackendMut::living_stop_gliding | crosses | Wire call |
BackendMut::living_start_sleeping | crosses | Wire call |
BackendMut::living_stop_sleeping | crosses | Wire call |
BackendMut::living_set_sleeping_pos | crosses | Wire call |
BackendMut::living_clear_sleeping_pos | crosses | Wire call |
BackendMut::living_set_is_jumping | crosses | Wire call |
BackendMut::living_set_health | crosses | Wire call |
BackendMut::living_heal | crosses | Wire call |
BackendMut::living_random_teleport | crosses | Wire call |
BackendMut::living_add_attribute_modifier | crosses | Wire call |
BackendMut::living_replace_attribute_modifier | crosses | Wire call |
BackendMut::living_remove_attribute_modifier | crosses | Wire call |
BackendMut::living_set_attribute_base | crosses | Wire call |
BackendMut::living_set_equipment | crosses | Wire call |
BackendMut::living_set_hand_item | crosses | Wire call |
BackendMut::living_hurt_and_break | crosses | Wire call |
BackendMut::living_drop | crosses | Wire call |
BackendMut::living_swing | crosses | Wire call |
BackendMut::living_arrow_count | crosses | Wire call |
BackendMut::living_set_arrow_count | crosses | Wire call |
BackendMut::living_stinger_count | crosses | Wire call |
BackendMut::living_set_stinger_count | crosses | Wire call |
BackendMut::living_set_arrow_removal_ticks | crosses | Wire call |
BackendMut::living_set_stinger_removal_ticks | crosses | Wire call |
BackendMut::living_set_item_use_remaining | crosses | Wire call |
BackendMut::living_start_using_item | crosses | Wire call |
BackendMut::living_release_using_item | crosses | Wire call |
BackendMut::living_set_waypoint_style | crosses | Wire call |
BackendMut::living_set_waypoint_color | crosses | Wire call |
BackendMut::living_set_riptiding | crosses | Wire call |
BackendMut::menu_close_container | crosses | Wire call |
BackendMut::merchant_set_offer | crosses | Wire call |
BackendMut::merchant_add_offer | crosses | Wire call |
BackendMut::merchant_remove_offer | crosses | Wire call |
BackendMut::merchant_set_profession | crosses | Wire call |
BackendMut::merchant_set_level | crosses | Wire call |
BackendMut::merchant_set_villager_type | crosses | Wire call |
BackendMut::merchant_set_xp | crosses | Wire call |
BackendMut::minecart_set_push | crosses | Wire call |
BackendMut::minecart_set_flipped | crosses | Wire call |
BackendMut::minecart_set_damage | crosses | Wire call |
BackendMut::minecart_set_hurt_time | crosses | Wire call |
BackendMut::minecart_set_custom_display_block | crosses | Wire call |
BackendMut::minecart_set_display_offset | crosses | Wire call |
BackendMut::minecart_set_fuel | crosses | Wire call |
BackendMut::minecart_prime_fuse | crosses | Wire call |
BackendMut::minecart_set_hopper_enabled | crosses | Wire call |
BackendMut::minecart_set_command | crosses | Wire call |
BackendMut::dragon_set_phase | crosses | Wire call |
BackendMut::mob_add_goal | crosses | Wire call |
BackendMut::mob_add_target_goal | crosses | Wire call |
BackendMut::mob_remove_goal | crosses | Wire call |
BackendMut::mob_remove_target_goal | crosses | Wire call |
BackendMut::mob_move_to | crosses | Wire call |
BackendMut::mob_stop | crosses | Wire call |
BackendMut::mob_set_navigation_speed | crosses | Wire call |
BackendMut::mob_set_pathfinding_malus | crosses | Wire call |
BackendMut::mob_set_target | crosses | Wire call |
BackendMut::mob_set_aggressive | crosses | Wire call |
BackendMut::mob_set_persistence_required | crosses | Wire call |
BackendMut::mob_set_no_ai | crosses | Wire call |
BackendMut::mob_set_baby | crosses | Wire call |
BackendMut::mob_look_at | crosses | Wire call |
BackendMut::mob_jump | crosses | Wire call |
BackendMut::mob_restrict_to | crosses | Wire call |
BackendMut::mob_clear_restriction | crosses | Wire call |
BackendMut::mob_set_left_handed | crosses | Wire call |
BackendMut::mob_set_can_pick_up_loot | crosses | Wire call |
BackendMut::mob_set_no_action_time | crosses | Wire call |
BackendMut::mob_set_brain_target | crosses | Wire call |
BackendMut::player_set_recipe_book_settings | crosses | Wire call |
BackendMut::player_tell | crosses | Wire call |
BackendMut::player_action_bar | crosses | Wire call |
BackendMut::player_tab_list | crosses | Wire call |
BackendMut::player_set_sleep_ignored | crosses | Wire call |
BackendMut::player_set_respawn_config | crosses | Wire call |
BackendMut::player_set_sleep_timer | crosses | Wire call |
BackendMut::player_set_nickname | crosses | Wire call |
BackendMut::player_hide | crosses | Wire call |
BackendMut::player_open_menu | crosses | Wire call |
BackendMut::player_send_map | crosses | Wire call |
BackendMut::player_set_abilities | crosses | Wire call |
BackendMut::player_perform_action | crosses | Wire call |
BackendMut::player_perform_action_scoped | crosses | Wire call |
BackendMut::player_set_item_cooldown | crosses | Wire call |
BackendMut::player_remove_item_cooldown | crosses | Wire call |
BackendMut::player_set_exp_cooldown | crosses | Wire call |
BackendMut::player_set_warden_warning_cooldown | crosses | Wire call |
BackendMut::player_trigger_criterion | crosses | Wire call |
BackendMut::player_trigger_block_use | crosses | Wire call |
BackendMut::player_trigger_item_used_on_block | crosses | Wire call |
BackendMut::player_set_last_death_location | crosses | Wire call |
BackendMut::player_feed | crosses | Wire call |
BackendMut::player_add_exhaustion | crosses | Wire call |
BackendMut::player_set_food_level | crosses | Wire call |
BackendMut::player_set_saturation | crosses | Wire call |
BackendMut::player_set_exhaustion | crosses | Wire call |
BackendMut::player_set_experience_points | crosses | Wire call |
BackendMut::player_set_experience_levels | crosses | Wire call |
BackendMut::player_set_game_mode | crosses | Wire call |
BackendMut::player_set_selected_slot | crosses | Wire call |
BackendMut::player_drop_stack | crosses | Wire call |
BackendMut::player_remove_fake | crosses | Wire call |
BackendMut::player_award_stat | crosses | Wire call |
BackendMut::player_reset_stat | crosses | Wire call |
BackendMut::player_award_recipes | crosses | Wire call |
BackendMut::player_reset_recipes | crosses | Wire call |
BackendMut::player_give_experience_points | crosses | Wire call |
BackendMut::player_give_levels | crosses | Wire call |
BackendMut::player_stop_sound | crosses | Wire call |
BackendMut::player_play_sound | crosses | Wire call |
BackendMut::player_simulate_move | crosses | Wire call |
BackendMut::projectile_set_owner | crosses | Wire call |
BackendMut::projectile_deflect | crosses | Wire call |
BackendMut::projectile_set_pickup | crosses | Wire call |
BackendMut::projectile_set_pierce_level | crosses | Wire call |
BackendMut::projectile_set_base_damage | crosses | Wire call |
BackendMut::projectile_set_critical | crosses | Wire call |
BackendMut::raid_set_omen_level | crosses | Wire call |
BackendMut::raid_stop | crosses | Wire call |
ClientBackendMut::entity_sprint_particles | crosses | Wire call |
ClientBackendMut::level_set_client_break_progress | crosses | Wire call |
ClientBackendMut::menu_click | crosses | Wire call |
ClientBackendMut::menu_close | crosses | Wire call |
ClientBackendMut::menu_click_button | crosses | Wire call |
ClientBackendMut::player_request_recipe_book_settings | crosses | Wire call |
DrawContext::size | crosses | Wire call |
DrawContext::item_model | crosses | Wire call |
DrawContext::quad | crosses | Wire call |
DrawContext::fill | crosses | Wire call |
DrawContext::sprite | crosses | Wire call |
DrawContext::item | crosses | Wire call |
DrawContext::text | crosses | Wire call |
DrawContext::item_stack | crosses | Wire call |
DrawContext::gui_paint | crosses | Wire call |
DrawContext::gui_nine_slice | crosses | Wire call |
DrawContext::gui_tooltip | crosses | Wire call |
DrawContext::text_width | crosses | Wire call |
DrawContext::fluid | crosses | Wire call |
DrawContext::draw_scene | crosses | Wire call |
DrawContext::draw_menu_background | crosses | Wire call |
DrawContext::tiled_sprite | crosses | Wire call |
DrawContext::gui_draw_item_with | crosses | Wire call |
DrawContext::widget_button | crosses | Wire call |
DrawContext::widget_text_field | crosses | Wire call |
BlockEntityDrawContext::push | crosses | Wire call |
BlockEntityDrawContext::pop | crosses | Wire call |
BlockEntityDrawContext::translate | crosses | Wire call |
BlockEntityDrawContext::rotate_about | crosses | Wire call |
BlockEntityDrawContext::scale | crosses | Wire call |
BlockEntityDrawContext::keep | crosses | Wire call |
BlockEntityDrawContext::partial | crosses | Wire call |
BlockEntityDrawContext::game_time | crosses | Wire call |
BlockEntityDrawContext::light | crosses | Wire call |
BlockEntityDrawContext::set_light | crosses | Wire call |
BlockEntityDrawContext::quad | crosses | Wire call |
BlockEntityDrawContext::cube | crosses | Wire call |
BlockEntityDrawContext::block | crosses | Wire call |
BlockEntityDrawContext::item | crosses | Wire call |
BlockEntityDrawContext::spin | crosses | Wire call |
BlockEntityDrawContext::still | crosses | Wire call |
BlockEntityDrawContext::model | crosses | Wire call |
BlockEntityDrawContext::terrain_model | crosses | Wire call |
BlockEntityDrawContext::text | crosses | Wire call |
BlockEntityDrawContext::text_width | crosses | Wire call |
BlockEntityDrawContext::model_part | crosses | Wire call |
BlockEntityDrawContext::fluid_box | crosses | Wire call |
WorldDrawContext::draw_instance_group | crosses | Wire call |
WorldDrawContext::lines | crosses | Wire call |
WorldDrawContext::quads | crosses | Wire call |
WorldDrawContext::pipeline_quads | crosses | Wire call |
WorldDrawContext::usage | crosses | Wire call |
WorldDrawContext::draw_model_instances | crosses | Wire call |
CameraDrawContext::quad | crosses | Wire call |
CameraDrawContext::sprite | crosses | Wire call |
LevelHost::block_entity_data | crosses | Wire call |
LevelHost::block_state | crosses | Wire call |
LevelHost::state_block | crosses | Wire call |
LevelHost::block_state_id | crosses | Wire call |
LevelHost::block_state_default | crosses | Wire call |
LevelHost::state_string | crosses | Wire call |
LevelHost::min_y | crosses | Wire call |
LevelHost::height | crosses | Wire call |
LevelHost::block_in_tag | crosses | Wire call |
LevelHost::fluid_state | crosses | Wire call |
LevelHost::state_fluid_state | crosses | Wire call |
LevelHost::fluid_in_tag | crosses | Wire call |
LevelHost::state_with_property | crosses | Wire call |
LevelHost::brightness | crosses | Wire call |
LevelHost::raw_brightness | crosses | Wire call |
LevelHost::is_loaded | crosses | Wire call |
LevelHost::is_raining_at | crosses | Wire call |
LevelHost::enabled_features | crosses | Wire call |
LevelRandomHost::attachments | crosses | frame accessor; no bytes |
LevelRandomHost::random | crosses | Wire call |
LevelRandomHost::set_random | crosses | Wire call |
LevelTicksHost::schedule_block_tick | crosses | Wire call |
LevelTicksHost::schedule_fluid_tick | crosses | Wire call |
LevelTicksHost::signal | crosses | Wire call |
LevelTicksHost::direct_signal | crosses | Wire call |
LevelTicksHost::direct_signal_to | crosses | Wire call |
LevelTicksHost::best_neighbor_signal | crosses | Wire call |
LevelTicksHost::has_neighbor_signal | crosses | Wire call |
LevelWriteHost::set_block | crosses | Wire call |
LevelWriteHost::remove_block | crosses | Wire call |
LevelWriteHost::set_block_property | crosses | Wire call |
LevelWriteHost::play_sound | crosses | Wire call |
LevelWriteHost::send_particles | crosses | Wire call |
LevelWriteHost::level_event | crosses | Wire call |
ConfigFiles::read | crosses | Wire call |
ConfigFiles::write | crosses | Wire call |
ConfigFiles::list | crosses | Wire call |
ConfigFiles::stat | crosses | Wire call |
Commands::refresh_commands | crosses | Wire call |
Commands::feedback | crosses | Wire call |
Commands::run_command | crosses | Wire call |
Levels::list_levels | crosses | Wire call |
Messages::text_allowed | crosses | Wire call |
Messages::broadcast | crosses | Wire call |
Players::server_players | crosses | Wire call |
Players::player_by_name | crosses | Wire call |
Players::acting_player | crosses | Wire call |
ClientSession::client_server | crosses | Wire call |
ClientSession::client_language | crosses | Wire call |
ClientSession::client_frame_stats | crosses | Wire call |
RenderAssets::model | crosses | Wire call |
RenderAssets::render_pipeline | crosses | Wire call |
RenderAssets::animation | crosses | Wire call |
InstanceGroups::create_group | crosses | Wire call |
Tags::key | crosses | Wire call |
Tags::is_in_tag | crosses | Wire call |
Tags::items | crosses | Wire call |
Tags::fluids | crosses | Wire call |
Tags::blocks | crosses | Wire call |
item::menu::SlotView::item | crosses | Wire call |
item::menu::SlotView::may_place | crosses | Wire call |
item::menu::SlotView::may_pickup | crosses | Wire call |
item::menu::SlotView::allow_modification | crosses | Wire call |
item::menu::SlotView::safe_insert | crosses | Wire call |
item::menu::SlotView::safe_take | crosses | Wire call |
Registrar::register_arm_pose | crosses | Wire call |
Registrar::declare_attribute | crosses | Wire call |
Registrar::declare_biome_modifier | crosses | Wire call |
Registrar::block_entity_type_key | crosses | Wire call |
Registrar::declare_block_entity_type | crosses | Wire call |
Registrar::attach_block_entity_part | crosses | Wire call |
Registrar::provide_block_entity_capability | crosses | Wire call |
Registrar::block_key | crosses | Wire call |
Registrar::attach_block_part | crosses | Wire call |
Registrar::attach_block_part_tag | crosses | Wire call |
Registrar::add_command_argument | crosses | Wire call |
Registrar::declare_datapack_registry | crosses | Wire call |
Registrar::add_dimension | crosses | Wire call |
Registrar::effect_key | crosses | Wire call |
Registrar::provide_entity_capability | crosses | Wire call |
Registrar::declare_entity_attributes | crosses | Wire call |
Registrar::modify_entity_attributes | crosses | Wire call |
Registrar::fluid_key | crosses | Wire call |
Registrar::declare_fluid | crosses | Wire call |
Registrar::declare_world_fluid | crosses | Wire call |
Registrar::attach_fluid_part | crosses | Wire call |
Registrar::game_rule_category | crosses | Wire call |
Registrar::game_rule_bool | crosses | Wire call |
Registrar::item_key | crosses | Wire call |
Registrar::attach_item_part | crosses | Wire call |
Registrar::attach_item_part_tag | crosses | Wire call |
Registrar::provide_item_capability | crosses | Wire call |
Registrar::place_provider | crosses | Wire call |
Registrar::register_tooltip_appender | crosses | Wire call |
Registrar::register_key_mapping | crosses | Wire call |
Registrar::menu_recipe_book | crosses | Wire call |
Registrar::attach_menu_part | crosses | Wire call |
Registrar::particle_key | crosses | Wire call |
Registrar::recipe_book_type | crosses | Wire call |
Registrar::registration_refusal | crosses | Wire call |
Registrar::register_setting | crosses | Wire call |
Registrar::declare_spawn_placement | crosses | Wire call |
Registrar::add_structure_type | crosses | Wire call |
Registrar::register_ticket_controller | crosses | Wire call |
CapabilityProviders::block_entity_provider | crosses | Wire call |
CapabilityProviders::provided | crosses | Wire call |
CapabilityProviders::entity_provider | crosses | Wire call |
CapabilityProviders::item_provider | crosses | Wire call |
CapabilityProviders::place_provider | crosses | Wire call |
TicketValidation::block_tickets | crosses | Wire call |
TicketValidation::entity_tickets | crosses | Wire call |
TicketValidation::remove_block_tickets | crosses | Wire call |
TicketValidation::remove_entity_tickets | crosses | Wire call |
TicketValidation::remove_block_ticket | crosses | Wire call |
TicketValidation::remove_entity_ticket | crosses | Wire call |
DataLoadContext::origin | crosses | Wire call |
DataLoadContext::registry | crosses | Wire call |
DataLoadContext::fluid_key | crosses | Wire call |
DataLoadContext::fluid_tags | crosses | Wire call |
DataLoadContext::setting | crosses | Wire call |
DataLoadContext::mods | crosses | Wire call |
LoopContext::mod_recipe_property_contains | crosses | Wire call |
LoopContext::side | crosses | Wire call |
LoopContext::registry | crosses | Wire call |
LoopContext::setting | crosses | Wire call |
LoopContext::mods | crosses | Wire call |
WorldgenHost::registry | crosses | Wire call |
WorldgenHost::setting | crosses | Wire call |
WorldgenHost::mods | crosses | Wire call |
GenerationContext::metadata | crosses | frame accessor; no bytes |
GenerationContext::chunk | crosses | Wire call |
GenerationContext::seed | crosses | Wire call |
GenerationContext::sea_level | crosses | Wire call |
GenerationContext::min_y | crosses | Wire call |
GenerationContext::max_y | crosses | Wire call |
GenerationContext::random | crosses | frame accessor; no bytes |
GenerationContext::base_height | crosses | Wire call |
GenerationContext::base_columns | crosses | Wire call |
GenerationContext::noise_column | crosses | Wire call |
GenerationContext::noise_biome | crosses | Wire call |
GenerationContext::column_has_valid_biome | crosses | Wire call |
GenerationContext::config | crosses | Wire call |
PieceContext::metadata | crosses | frame accessor; no bytes |
PieceContext::chunk | crosses | Wire call |
PieceContext::chunk_box | crosses | Wire call |
PieceContext::random | crosses | frame accessor; no bytes |
PieceContext::state | crosses | Wire call |
PieceContext::state_with | crosses | Wire call |
PieceContext::is_air | crosses | Wire call |
PieceContext::block | crosses | Wire call |
PieceContext::set_block | crosses | Wire call |
PieceContext::height | crosses | Wire call |
PieceContext::solid_render | crosses | Wire call |
PieceContext::entity | crosses | Wire call |
PieceContext::block_entity | crosses | Wire call |
StructureRandom::next_int_bound | crosses | Wire call |
StructureRandom::next_long | crosses | Wire call |
StructureRandom::next_float | crosses | Wire call |
StructureRandom::next_double | crosses | Wire call |
StructureRandom::next_boolean | crosses | Wire call |
AttachmentStore::holds | crosses | Wire call |
AttachmentStore::decoded | crosses | Wire call |
AttachmentStore::saved | crosses | Wire call |
AttachmentStore::store | crosses | Wire call |
AttachmentStore::discard | crosses | Wire call |
AttachmentStore::queue_sync | crosses | Wire call |
ServerReloadListeners::add_listener | crosses | Wire call |
ServerReloadListeners::add_retained | crosses | Wire call |
ServerReloadListeners::add_dependency | crosses | Wire call |
ServerReloadListeners::get_registry | crosses | Wire call |
ServerReloadListeners::get_graph | crosses | Wire call |
ServerReloadListeners::get_last_vanilla_listener | crosses | Wire call |
GameTests::register_test | crosses | Wire call |
GameTests::register | crosses | Wire call |
ClientReloadListeners::add_listener | crosses | Wire call |
ClientReloadListeners::add_dependency | crosses | Wire call |
ClientReloadListeners::get_registry | crosses | Wire call |
ClientReloadListeners::get_graph | crosses | Wire call |
ClientReloadListeners::get_last_vanilla_listener | crosses | Wire call |
PackFinders::pack_type | crosses | Wire call |
PackFinders::add_pack_finders | crosses | Wire call |
ColorResolvers::register | crosses | Wire call |
BlockTintSources::register | crosses | Wire call |
CauldronInteractions::register | crosses | Wire call |
CauldronInteractions::register_to_all | crosses | Wire call |
SectionGeometryRenderers::register | crosses | Wire call |
SectionGeometryContext::level | crosses | frame accessor; no bytes |
SectionGeometryContext::add | crosses | Wire call |
SectionGeometryContext::remaining | crosses | Wire call |
ParticleProviders::register | crosses | Wire call |
EnchantmentDamageEffects::register | crosses | Wire call |
PoiTypeExtensions::metadata | crosses | frame accessor; no bytes |
PoiTypeExtensions::extend | crosses | Wire call |
BlockAndTintGetter::block_state | crosses | Wire call |
BlockAndTintGetter::fluid_state | crosses | Wire call |
BlockAndTintGetter::brightness | crosses | Wire call |
BlockAndTintGetter::raw_brightness | crosses | Wire call |
BlockAndTintGetter::can_see_sky | crosses | Wire call |
BlockAndTintGetter::shade | crosses | Wire call |
BlockAndTintGetter::block_tint | crosses | Wire call |
BlockAndTintGetter::min_y | crosses | Wire call |
BlockAndTintGetter::height | crosses | Wire call |
GroundLevel::block_state | crosses | Wire call |
GroundLevel::decorator_state | crosses | Wire call |
GroundLevel::random | crosses | frame accessor; no bytes |
BlockMetadata::name | crosses | Wire call |
BlockMetadata::property | crosses | Wire call |
BlockMetadata::with | crosses | Wire call |
BlockMetadata::of | crosses | Wire call |
ServerReloadResources::retained | crosses | Wire call |
CriterionContext::conditions_json | crosses | Wire call |
CriterionContext::item | crosses | Wire call |
UnspawnedMobEdits::set_equipment | crosses | Wire call |
UnspawnedMobEdits::set_attribute_base | crosses | Wire call |
UnspawnedMobEdits::add_attribute_modifier | crosses | Wire call |
UnspawnedMobEdits::remove_attribute_modifier | crosses | Wire call |
UnspawnedMobEdits::add_tag | crosses | Wire call |
UnspawnedMobEdits::remove_tag | crosses | Wire call |
UnspawnedMobEdits::set_custom_name | crosses | Wire call |
Hooks::subscribed | crosses | Wire call |
Hooks::register | crosses | Wire call |
Hooks::visuals | crosses | Wire call |
Hooks::emissive | crosses | Wire call |
Hooks::world_lifecycle | crosses | Wire call |
Hooks::server_about_to_start | crosses | Wire call |
Hooks::server_starting | crosses | Wire call |
Hooks::server_started | crosses | Wire call |
Hooks::server_stopping | crosses | Wire call |
Hooks::server_stopped | crosses | Wire call |
Hooks::server_tick | crosses | Wire call |
Hooks::level_tick_pre | crosses | Wire call |
Hooks::level_tick_post | crosses | Wire call |
Hooks::block_changed | crosses | Wire call |
Hooks::command | crosses | Wire call |
Hooks::register_client_commands | crosses | Wire call |
Hooks::zoom | crosses | Wire call |
Hooks::minimap | crosses | Wire call |
Hooks::setting_changed | crosses | Wire call |
Hooks::panel_event | crosses | Wire call |
Hooks::block_entity_container | crosses | Wire call |
Hooks::register_commands | crosses | Wire call |
Hooks::run_mod_command | crosses | Wire call |
Hooks::menu_clicked | crosses | Wire call |
Hooks::menu_closed | crosses | Wire call |
Hooks::break_block | crosses | Wire call |
Hooks::right_click_block | crosses | Wire call |
Hooks::block_spread | crosses | Wire call |
Hooks::piston_pre | crosses | Wire call |
Hooks::piston_post | crosses | Wire call |
Hooks::explosion_detonate | crosses | Wire call |
Hooks::fill_bucket | crosses | Wire call |
Hooks::attack_entity | crosses | Wire call |
Hooks::net_from_client | crosses | Wire call |
Hooks::net_from_server | crosses | Wire call |
Hooks::player_died | crosses | Wire call |
Hooks::mob_spawned | crosses | Wire call |
Hooks::mob_killed | crosses | Wire call |
Hooks::item_picked_up | crosses | Wire call |
Hooks::on_equip | crosses | Wire call |
Hooks::health_hud | crosses | Wire call |
Hooks::entity_interact | crosses | Wire call |
Hooks::left_click_block | crosses | Wire call |
Hooks::player_tick_pre | crosses | Wire call |
Hooks::player_tick_post | crosses | Wire call |
Hooks::entity_join_level | crosses | Wire call |
Hooks::living_incoming_damage | crosses | Wire call |
Hooks::entity_leave_level | crosses | Wire call |
Hooks::block_entity_fluids | crosses | Wire call |
Hooks::should_apply_effect_tick_this_tick | crosses | Wire call |
Hooks::apply_effect_tick | crosses | Wire call |
Hooks::on_effect_added | crosses | Wire call |
Hooks::mob_effect_added | crosses | Wire call |
Hooks::chunk_load | crosses | Wire call |
Hooks::chunk_unload | crosses | Wire call |
Hooks::player_logged_in | crosses | Wire call |
Hooks::player_logged_out | crosses | Wire call |
Hooks::component_types | crosses | Wire call |
Hooks::block_step_on | crosses | Wire call |
Hooks::consume_effect | crosses | Wire call |
Hooks::loot_table_load | crosses | Wire call |
Hooks::loot_drops | crosses | Wire call |
Hooks::enchant_effect | crosses | Wire call |
Hooks::screen_render_post | crosses | Wire call |
Hooks::modify_default_components | crosses | Wire call |
Hooks::screen_mouse_button_pressed_pre | crosses | Wire call |
Hooks::screen_mouse_button_released_pre | crosses | Wire call |
Hooks::camera_shake | crosses | Wire call |
Hooks::shader_params | crosses | Wire call |
Hooks::block_highlight | crosses | Wire call |
Hooks::render_hand | crosses | Wire call |
Hooks::client_resources_reloaded | crosses | Wire call |
Hooks::can_eat | crosses | Wire call |
Hooks::living_entity_use_item_start | crosses | Wire call |
Hooks::living_entity_use_item_finish | crosses | Wire call |
Hooks::screen_mouse_scrolled_pre | crosses | Wire call |
Hooks::player_command | crosses | Wire call |
Hooks::screen_key_pressed_pre | crosses | Wire call |
Hooks::screen_char_typed_pre | crosses | Wire call |
Hooks::data_pack_files | crosses | Wire call |
Hooks::biome_colors | crosses | Wire call |
Hooks::compute_fov | crosses | Wire call |
Hooks::mob_submit | crosses | Wire call |
Hooks::camera_distance | crosses | Wire call |
Hooks::compute_fog_color | crosses | Wire call |
Hooks::mouse_button_pre | crosses | Wire call |
Hooks::mouse_button_post | crosses | Wire call |
Hooks::player_respawn_position | crosses | Wire call |
Hooks::player_respawned | crosses | Wire call |
Hooks::render_fog | crosses | Wire call |
Hooks::screen_key_released_pre | crosses | Wire call |
Hooks::music | crosses | Wire call |
Hooks::music_layers | crosses | Wire call |
Hooks::datapack_sync | crosses | Wire call |
Hooks::recipes_received | crosses | Wire call |
Hooks::input_key | crosses | Wire call |
Hooks::mouse_scrolling | crosses | Wire call |
Hooks::screen_mouse_dragged_pre | crosses | Wire call |
Hooks::mod_container_viewers | crosses | Wire call |
Hooks::living_knockback | crosses | Wire call |
Hooks::experience_orb_can_merge | crosses | Wire call |
Hooks::experience_orb_merge | crosses | Wire call |
Hooks::living_entity_use_item_tick | crosses | Wire call |
Hooks::living_entity_use_item_stop | crosses | Wire call |
Hooks::loot_function | crosses | Wire call |
Hooks::loot_condition | crosses | Wire call |
Hooks::level_load | crosses | Wire call |
Hooks::level_unload | crosses | Wire call |
Hooks::client_level_load | crosses | Wire call |
Hooks::client_level_unload | crosses | Wire call |
Hooks::hears_projectile_impacts | crosses | Wire call |
Hooks::projectile_impact | crosses | Wire call |
Hooks::entity_mount | crosses | Wire call |
Hooks::interaction_key_mapping_triggered | crosses | Wire call |
Hooks::gui_layer_pre | crosses | Wire call |
Hooks::gui_layer_post | crosses | Wire call |
Hooks::map_decorations | crosses | Wire call |
Hooks::player_start_tracking | crosses | Wire call |
Hooks::player_stop_tracking | crosses | Wire call |
Hooks::block_entity_energy | crosses | Wire call |
Hooks::data_map_types | crosses | Wire call |
Hooks::living_breathe | crosses | Wire call |
Hooks::blocks | crosses | Wire call |
Hooks::items | crosses | Wire call |
Hooks::entity_tick_pre | crosses | Wire call |
Hooks::entity_tick_post | crosses | Wire call |
Hooks::entity_tick_listened | crosses | Wire call |
Hooks::living_equipment_change | crosses | Wire call |
Hooks::living_equipment_listened | crosses | Wire call |
Hooks::player_submit | crosses | Wire call |
Hooks::mob_layer | crosses | Wire call |
Hooks::player_render_listened | crosses | Wire call |
Hooks::sign_changed | crosses | Wire call |
Hooks::standalone_models | crosses | Wire call |
Hooks::player_container_open | crosses | Wire call |
Hooks::player_container_close | crosses | Wire call |
Hooks::player_toggle_flight | crosses | Wire call |
Hooks::anvil_damaged | crosses | Wire call |
Hooks::arrow_loose | crosses | Wire call |
Hooks::beacon_state_change | crosses | Wire call |
Hooks::bell_ring | crosses | Wire call |
Hooks::block_break_progress_update | crosses | Wire call |
Hooks::block_burn | crosses | Wire call |
Hooks::block_cook | crosses | Wire call |
Hooks::block_damage_abort | crosses | Wire call |
Hooks::block_dispense_armor | crosses | Wire call |
Hooks::block_fade | crosses | Wire call |
Hooks::block_failed_dispense | crosses | Wire call |
Hooks::block_form | crosses | Wire call |
Hooks::block_ignite | crosses | Wire call |
Hooks::block_lock_check | crosses | Wire call |
Hooks::block_receive_game | crosses | Wire call |
Hooks::block_shear_entity | crosses | Wire call |
Hooks::brewing_complete | crosses | Wire call |
Hooks::brewing_fuel | crosses | Wire call |
Hooks::brewing_start | crosses | Wire call |
Hooks::broadcast_message | crosses | Wire call |
Hooks::campfire_start | crosses | Wire call |
Hooks::cauldron_level_changed | crosses | Wire call |
Hooks::item_consumption_teleport | crosses | Wire call |
Hooks::compost_item | crosses | Wire call |
Hooks::crafter_craft | crosses | Wire call |
Hooks::creeper_ignite | crosses | Wire call |
Hooks::creeper_power | crosses | Wire call |
Hooks::critical_hit | crosses | Wire call |
Hooks::difficulty_changed | crosses | Wire call |
Hooks::dragon_egg_form | crosses | Wire call |
Hooks::enchant_item | crosses | Wire call |
Hooks::ender_pearl_teleport | crosses | Wire call |
Hooks::entities_load | crosses | Wire call |
Hooks::entities_unload | crosses | Wire call |
Hooks::entity_break_door | crosses | Wire call |
Hooks::entity_breed | crosses | Wire call |
Hooks::entity_change_block | crosses | Wire call |
Hooks::entity_drop_item | crosses | Wire call |
Hooks::entity_enter_block | crosses | Wire call |
Hooks::entity_enter_love_mode | crosses | Wire call |
Hooks::entity_portal | crosses | Wire call |
Hooks::entity_pose_change | crosses | Wire call |
Hooks::entity_shoot_bow | crosses | Wire call |
Hooks::entity_tame | crosses | Wire call |
Hooks::entity_teleport | crosses | Wire call |
Hooks::entity_toggle_glide | crosses | Wire call |
Hooks::entity_unleash | crosses | Wire call |
Hooks::exp_bottle | crosses | Wire call |
Hooks::pickup_xp | crosses | Wire call |
Hooks::mending_repair | crosses | Wire call |
Hooks::explosion_prime | crosses | Wire call |
Hooks::firework_explode | crosses | Wire call |
Hooks::fluid_level_change | crosses | Wire call |
Hooks::framerate_limit | crosses | Wire call |
Hooks::furnace_burn | crosses | Wire call |
Hooks::furnace_extract | crosses | Wire call |
Hooks::furnace_smelt | crosses | Wire call |
Hooks::furnace_start_smelt | crosses | Wire call |
Hooks::hopper_inventory_search | crosses | Wire call |
Hooks::hopper_pickup_item | crosses | Wire call |
Hooks::ingredient | crosses | Wire call |
Hooks::inventory_block_start | crosses | Wire call |
Hooks::inventory_move_item | crosses | Wire call |
Hooks::item_crafted | crosses | Wire call |
Hooks::item_despawn | crosses | Wire call |
Hooks::item_expire | crosses | Wire call |
Hooks::item_spawn | crosses | Wire call |
Hooks::leaves_decay | crosses | Wire call |
Hooks::lightning_strike | crosses | Wire call |
Hooks::moisture_change | crosses | Wire call |
Hooks::anvil_craft_post | crosses | Wire call |
Hooks::anvil_craft_pre | crosses | Wire call |
Hooks::armor_stand_manipulate | crosses | Wire call |
Hooks::bucket_empty | crosses | Wire call |
Hooks::container_opened | crosses | Wire call |
Hooks::creative_slot | crosses | Wire call |
Hooks::food_level_changed | crosses | Wire call |
Hooks::hanging_break | crosses | Wire call |
Hooks::hanging_place | crosses | Wire call |
Hooks::player_changed_dimension | crosses | Wire call |
Hooks::player_clone | crosses | Wire call |
Hooks::player_commands_sent | crosses | Wire call |
Hooks::player_destroy_item | crosses | Wire call |
Hooks::player_fish | crosses | Wire call |
Hooks::player_flower_pot_manipulate | crosses | Wire call |
Hooks::player_game_mode_changed | crosses | Wire call |
Hooks::player_insert_lectern_book | crosses | Wire call |
Hooks::player_item_frame_change | crosses | Wire call |
Hooks::player_item_mend | crosses | Wire call |
Hooks::player_lectern_page_change | crosses | Wire call |
Hooks::player_login | crosses | Wire call |
Hooks::player_sprint | crosses | Wire call |
Hooks::player_swap_hands | crosses | Wire call |
Hooks::player_take_lectern_book | crosses | Wire call |
Hooks::player_teleported | crosses | Wire call |
Hooks::player_totem | crosses | Wire call |
Hooks::resource_pack_status | crosses | Wire call |
Hooks::spawn_change | crosses | Wire call |
Hooks::tnt_primed | crosses | Wire call |
Hooks::vehicle_create | crosses | Wire call |
Hooks::vehicle_damage | crosses | Wire call |
Hooks::vehicle_destroy | crosses | Wire call |
Hooks::vehicle_moved | crosses | Wire call |
Hooks::world_border_bounds_changed | crosses | Wire call |
Hooks::world_border_center_changed | crosses | Wire call |
Hooks::player_advancement_done | crosses | Wire call |
Hooks::player_advancement_progress | crosses | Wire call |
Hooks::player_arm_swing | crosses | Wire call |
Hooks::player_attack_cooldown_reset | crosses | Wire call |
Hooks::player_bed_enter | crosses | Wire call |
Hooks::player_bed_leave | crosses | Wire call |
Hooks::player_bucket_entity | crosses | Wire call |
Hooks::player_bundle_item_select | crosses | Wire call |
Hooks::player_change_beacon_effect | crosses | Wire call |
Hooks::player_client_loaded_world | crosses | Wire call |
Hooks::player_deep_sleep | crosses | Wire call |
Hooks::player_edit_book | crosses | Wire call |
Hooks::player_egg_throw | crosses | Wire call |
Hooks::player_elytra_boost | crosses | Wire call |
Hooks::player_exp_cooldown_change | crosses | Wire call |
Hooks::player_input | crosses | Wire call |
Hooks::player_item_cooldown | crosses | Wire call |
Hooks::player_switch_hotbar_slot_pre | crosses | Wire call |
Hooks::player_switch_hotbar_slot_post | crosses | Wire call |
Hooks::player_jump | crosses | Wire call |
Hooks::player_kicked | crosses | Wire call |
Hooks::player_launch_projectile | crosses | Wire call |
Hooks::player_level_change | crosses | Wire call |
Hooks::player_loom_pattern_select | crosses | Wire call |
Hooks::player_map_filled | crosses | Wire call |
Hooks::player_name_entity | crosses | Wire call |
Hooks::player_open_sign | crosses | Wire call |
Hooks::player_pick_item | crosses | Wire call |
Hooks::player_pickup_arrow | crosses | Wire call |
Hooks::player_ready_arrow | crosses | Wire call |
Hooks::player_recipe_book_click | crosses | Wire call |
Hooks::player_recipe_book_settings_change | crosses | Wire call |
Hooks::player_recipe_discovered | crosses | Wire call |
Hooks::player_riptide | crosses | Wire call |
Hooks::player_shear_entity | crosses | Wire call |
Hooks::player_shield_disable | crosses | Wire call |
Hooks::player_stat_award | crosses | Wire call |
Hooks::player_stonecutter_recipe_select | crosses | Wire call |
Hooks::player_stop_spectating_entity | crosses | Wire call |
Hooks::player_stop_using_item | crosses | Wire call |
Hooks::player_swap_with_equipment_slot | crosses | Wire call |
Hooks::player_teleport_end_gateway | crosses | Wire call |
Hooks::player_toggle_entity_age_lock | crosses | Wire call |
Hooks::player_toggle_sneak | crosses | Wire call |
Hooks::player_trade | crosses | Wire call |
Hooks::player_unleash_entity | crosses | Wire call |
Hooks::player_velocity | crosses | Wire call |
Hooks::portal_create | crosses | Wire call |
Hooks::potion_splash | crosses | Wire call |
Hooks::prepare_hopper_transfer | crosses | Wire call |
Hooks::prepare_item_craft | crosses | Wire call |
Hooks::prepare_item_enchant | crosses | Wire call |
Hooks::prepare_smithing | crosses | Wire call |
Hooks::projectile_launch | crosses | Wire call |
Hooks::raid_spawn_wave | crosses | Wire call |
Hooks::raid_state_change | crosses | Wire call |
Hooks::sculk_bloom | crosses | Wire call |
Hooks::sleep_finished_time | crosses | Wire call |
Hooks::sponge_absorb | crosses | Wire call |
Hooks::tab_complete | crosses | Wire call |
Hooks::target_hit | crosses | Wire call |
Hooks::thunder_change | crosses | Wire call |
Hooks::time_skip | crosses | Wire call |
Hooks::trade_select | crosses | Wire call |
Hooks::trade_with_villager | crosses | Wire call |
Hooks::vault_change_state | crosses | Wire call |
Hooks::vault_loot | crosses | Wire call |
Hooks::weather_change | crosses | Wire call |
Hooks::whitelist_state_update | crosses | Wire call |
Hooks::world_border_bounds_change_finish | crosses | Wire call |
Hooks::world_saved | crosses | Wire call |
Hooks::living_drown | crosses | Wire call |
Hooks::living_fall | crosses | Wire call |
Hooks::living_heal | crosses | Wire call |
Hooks::living_jump | crosses | Wire call |
Hooks::add_attribute_tooltips | crosses | Wire call |
Hooks::add_client_reload_listeners | crosses | Wire call |
Hooks::add_pack_finders | crosses | Wire call |
Hooks::add_section_geometry | crosses | Wire call |
Hooks::add_server_reload_listeners | crosses | Wire call |
Hooks::alter_ground | crosses | Wire call |
Hooks::animal_tame | crosses | Wire call |
Hooks::anvil_update | crosses | Wire call |
Hooks::arm_pose_transform | crosses | Wire call |
Hooks::armor_hurt | crosses | Wire call |
Hooks::arrow_nock | crosses | Wire call |
Hooks::baby_entity_spawn | crosses | Wire call |
Hooks::bake_model_loader | crosses | Wire call |
Hooks::block_drops | crosses | Wire call |
Hooks::block_entity_type_add_blocks | crosses | Wire call |
Hooks::block_grow_feature | crosses | Wire call |
Hooks::block_screen_effect_draw | crosses | Wire call |
Hooks::block_tool_modification | crosses | Wire call |
Hooks::bonemeal | crosses | Wire call |
Hooks::build_creative_mode_tab_contents | crosses | Wire call |
Hooks::calculate_player_turn | crosses | Wire call |
Hooks::camera_pose | crosses | Wire call |
Hooks::camera_type_cycle | crosses | Wire call |
Hooks::can_continue_sleeping | crosses | Wire call |
Hooks::can_player_sleep | crosses | Wire call |
Hooks::chat_suggestion_applied | crosses | Wire call |
Hooks::chunk_ticket_level_updated | crosses | Wire call |
Hooks::chunk_watch | crosses | Wire call |
Hooks::chunk_sent | crosses | Wire call |
Hooks::chunk_unwatch | crosses | Wire call |
Hooks::client_attack_target | crosses | Wire call |
Hooks::client_chat | crosses | Wire call |
Hooks::client_chat_received | crosses | Wire call |
Hooks::client_information_updated | crosses | Wire call |
Hooks::client_mining_predict | crosses | Wire call |
Hooks::client_packet | crosses | Wire call |
Hooks::client_packet_send | crosses | Wire call |
Hooks::client_pause_change_pre | crosses | Wire call |
Hooks::client_pause_change_post | crosses | Wire call |
Hooks::client_player_change_game_type | crosses | Wire call |
Hooks::client_player_clone | crosses | Wire call |
Hooks::client_player_logging_in | crosses | Wire call |
Hooks::client_player_logging_out | crosses | Wire call |
Hooks::client_disconnecting | crosses | Wire call |
Hooks::menu_background | crosses | Wire call |
Hooks::client_setup | crosses | Wire call |
Hooks::client_stopped | crosses | Wire call |
Hooks::client_stopping | crosses | Wire call |
Hooks::client_tick_post | crosses | Wire call |
Hooks::client_tick_pre | crosses | Wire call |
Hooks::component_hover | crosses | Wire call |
Hooks::compute_fov_modifier | crosses | Wire call |
Hooks::create_fluid_source | crosses | Wire call |
Hooks::create_spawn_position | crosses | Wire call |
Hooks::crop_grow_pre | crosses | Wire call |
Hooks::crop_grow_post | crosses | Wire call |
Hooks::custom_click_action | crosses | Wire call |
Hooks::modify_custom_spawners | crosses | Wire call |
Hooks::custom_spawner_tick | crosses | Wire call |
Hooks::customize_boss_event_progress | crosses | Wire call |
Hooks::customize_chat_overlay | crosses | Wire call |
Hooks::data_maps_updated | crosses | Wire call |
Hooks::debug_entry_lines | crosses | Wire call |
Hooks::debug_renderer_draw | crosses | Wire call |
Hooks::dimension_transition_screen_draw | crosses | Wire call |
Hooks::effect_particle_modification | crosses | Wire call |
Hooks::enchanted_block_loot | crosses | Wire call |
Hooks::enchanted_entity_loot | crosses | Wire call |
Hooks::enchantment_level_set | crosses | Wire call |
Hooks::ender_entity_teleport | crosses | Wire call |
Hooks::enderman_anger | crosses | Wire call |
Hooks::entity_change_block_pre | crosses | Wire call |
Hooks::entity_constructing | crosses | Wire call |
Hooks::entity_interact_specific | crosses | Wire call |
Hooks::entity_invulnerability_check | crosses | Wire call |
Hooks::entity_mob_griefing | crosses | Wire call |
Hooks::entity_outline | crosses | Wire call |
Hooks::entity_place | crosses | Wire call |
Hooks::entity_size | crosses | Wire call |
Hooks::entity_struck_by_lightning | crosses | Wire call |
Hooks::entity_travel_to_dimension | crosses | Wire call |
Hooks::explosion_knockback | crosses | Wire call |
Hooks::explosion_start | crosses | Wire call |
Hooks::extend_poi_types | crosses | Wire call |
Hooks::extract_block_screen_effect | crosses | Wire call |
Hooks::farmland_trample | crosses | Wire call |
Hooks::finalize_spawn | crosses | Wire call |
Hooks::flip_frame | crosses | Wire call |
Hooks::fluid_flow | crosses | Wire call |
Hooks::fluid_place_block | crosses | Wire call |
Hooks::fluid_tick | crosses | Wire call |
Hooks::fluid_tooltip | crosses | Wire call |
Hooks::game_rule_changed | crosses | Wire call |
Hooks::game_shutting_down | crosses | Wire call |
Hooks::gather_effect_screen_tooltips | crosses | Wire call |
Hooks::gather_skipped_attribute_tooltips | crosses | Wire call |
Hooks::get_arm_pose | crosses | Wire call |
Hooks::get_enchantment_level | crosses | Wire call |
Hooks::grindstone_place | crosses | Wire call |
Hooks::grindstone_take | crosses | Wire call |
Hooks::gui_layer_draw | crosses | Wire call |
Hooks::gui_render_post | crosses | Wire call |
Hooks::ingredient_test | crosses | Wire call |
Hooks::item_attribute_modifier | crosses | Wire call |
Hooks::item_container | crosses | Wire call |
Hooks::item_fished | crosses | Wire call |
Hooks::item_model_property | crosses | Wire call |
Hooks::item_model_range_property | crosses | Wire call |
Hooks::item_model_select_property | crosses | Wire call |
Hooks::item_model_update | crosses | Wire call |
Hooks::item_smelted | crosses | Wire call |
Hooks::item_stacked_on_other | crosses | Wire call |
Hooks::item_tint_source | crosses | Wire call |
Hooks::item_tooltip | crosses | Wire call |
Hooks::item_toss | crosses | Wire call |
Hooks::key_mapping_pre | crosses | Wire call |
Hooks::left_click_empty | crosses | Wire call |
Hooks::level_save | crosses | Wire call |
Hooks::play_level_sound_at_position | crosses | Wire call |
Hooks::play_level_sound_at_entity | crosses | Wire call |
Hooks::living_change_target | crosses | Wire call |
Hooks::living_conversion_post | crosses | Wire call |
Hooks::living_conversion_pre | crosses | Wire call |
Hooks::living_damage_post | crosses | Wire call |
Hooks::living_damage_pre | crosses | Wire call |
Hooks::living_death | crosses | Wire call |
Hooks::living_death_post | crosses | Wire call |
Hooks::living_destroy_block | crosses | Wire call |
Hooks::living_drops | crosses | Wire call |
Hooks::get_equipment_drop_chance | crosses | Wire call |
Hooks::living_experience_drop | crosses | Wire call |
Hooks::living_get_projectile | crosses | Wire call |
Hooks::living_jump_power | crosses | Wire call |
Hooks::living_speed | crosses | Wire call |
Hooks::living_flying_speed | crosses | Wire call |
Hooks::living_shield_block | crosses | Wire call |
Hooks::living_use_totem | crosses | Wire call |
Hooks::living_visibility | crosses | Wire call |
Hooks::map_decoration_types | crosses | Wire call |
Hooks::menu_extra_data | crosses | Wire call |
Hooks::mob_despawn | crosses | Wire call |
Hooks::mob_effect_applicable | crosses | Wire call |
Hooks::mob_effect_expired | crosses | Wire call |
Hooks::mob_effect_remove | crosses | Wire call |
Hooks::mob_spawn_placement_check | crosses | Wire call |
Hooks::mob_spawn_position_check | crosses | Wire call |
Hooks::mob_split | crosses | Wire call |
Hooks::register_configs | crosses | Wire call |
Hooks::config_loading | crosses | Wire call |
Hooks::config_reloading | crosses | Wire call |
Hooks::config_unloading | crosses | Wire call |
Hooks::mod_mismatch | crosses | Wire call |
Hooks::model_baking_completed | crosses | Wire call |
Hooks::movement_input | crosses | Wire call |
Hooks::note_block_change | crosses | Wire call |
Hooks::potential_spawns | crosses | Wire call |
Hooks::village_siege | crosses | Wire call |
Hooks::neighbor_notify | crosses | Wire call |
Hooks::note_block_play | crosses | Wire call |
Hooks::permissions_changed | crosses | Wire call |
Hooks::player_break_speed | crosses | Wire call |
Hooks::player_brewed_potion | crosses | Wire call |
Hooks::player_enchant_item | crosses | Wire call |
Hooks::player_flyable_fall | crosses | Wire call |
Hooks::player_harvest_check | crosses | Wire call |
Hooks::player_heart_type | crosses | Wire call |
Hooks::player_inventory_changed | crosses | Wire call |
Hooks::player_item_smithed | crosses | Wire call |
Hooks::player_name_format | crosses | Wire call |
Hooks::player_set_spawn | crosses | Wire call |
Hooks::player_spawn_phantoms | crosses | Wire call |
Hooks::player_stays_on_ground_surface | crosses | Wire call |
Hooks::player_tab_list_name_format | crosses | Wire call |
Hooks::player_xp_change | crosses | Wire call |
Hooks::player_xp_level_change | crosses | Wire call |
Hooks::portal_spawn | crosses | Wire call |
Hooks::potion_brew_post | crosses | Wire call |
Hooks::potion_brew_pre | crosses | Wire call |
Hooks::recipe_book_categories | crosses | Wire call |
Hooks::recipe_property_sets | crosses | Wire call |
Hooks::register_block_tint_sources | crosses | Wire call |
Hooks::register_cauldron_interaction | crosses | Wire call |
Hooks::register_color_resolvers | crosses | Wire call |
Hooks::register_debug_entries | crosses | Wire call |
Hooks::register_debug_renderers | crosses | Wire call |
Hooks::register_dimension_transition_screen | crosses | Wire call |
Hooks::register_enchantment_damage_effects | crosses | Wire call |
Hooks::register_entity_spectator_shaders | crosses | Wire call |
Hooks::register_fluid_models | crosses | Wire call |
Hooks::register_game_tests | crosses | Wire call |
Hooks::register_gui_layers | crosses | Wire call |
Hooks::register_item_models | crosses | Wire call |
Hooks::register_item_tint_sources | crosses | Wire call |
Hooks::register_layer_definitions | crosses | Wire call |
Hooks::register_model_loaders | crosses | Wire call |
Hooks::register_particle_groups | crosses | Wire call |
Hooks::register_particle_providers | crosses | Wire call |
Hooks::register_preset_editors | crosses | Wire call |
Hooks::register_range_select_item_model_property | crosses | Wire call |
Hooks::register_render_pipelines | crosses | Wire call |
Hooks::register_runtime_sprites | crosses | Wire call |
Hooks::register_select_item_model_property | crosses | Wire call |
Hooks::register_sprite_sources | crosses | Wire call |
Hooks::register_texture_atlases | crosses | Wire call |
Hooks::render_arm | crosses | Wire call |
Hooks::render_frame_post | crosses | Wire call |
Hooks::render_frame_pre | crosses | Wire call |
Hooks::render_gui_post | crosses | Wire call |
Hooks::render_gui_pre | crosses | Wire call |
Hooks::render_item_in_frame | crosses | Wire call |
Hooks::render_level_stage | crosses | Wire call |
Hooks::render_name_tag | crosses | Wire call |
Hooks::render_tooltip_gather_components | crosses | Wire call |
Hooks::render_tooltip_pre | crosses | Wire call |
Hooks::render_tooltip_texture | crosses | Wire call |
Hooks::right_click_empty | crosses | Wire call |
Hooks::right_click_item | crosses | Wire call |
Hooks::scheduled_task | crosses | Wire call |
Hooks::screen_closing | crosses | Wire call |
Hooks::screen_exclusion_areas | crosses | Wire call |
Hooks::screen_init_post | crosses | Wire call |
Hooks::screen_mouse_button_pressed_post | crosses | Wire call |
Hooks::screen_mouse_button_released_post | crosses | Wire call |
Hooks::screen_mouse_dragged_post | crosses | Wire call |
Hooks::screen_mouse_scrolled_post | crosses | Wire call |
Hooks::screen_key_pressed_post | crosses | Wire call |
Hooks::screen_key_released_post | crosses | Wire call |
Hooks::screen_char_typed_post | crosses | Wire call |
Hooks::screen_opening | crosses | Wire call |
Hooks::screen_preedit_pre | crosses | Wire call |
Hooks::screen_preedit_post | crosses | Wire call |
Hooks::screen_render_inventory_mob_effects | crosses | Wire call |
Hooks::screen_render_pre | crosses | Wire call |
Hooks::screen_render_background | crosses | Wire call |
Hooks::screen_render_foreground | crosses | Wire call |
Hooks::screen_transparent_background | crosses | Wire call |
Hooks::screenshot | crosses | Wire call |
Hooks::server_chat | crosses | Wire call |
Hooks::server_tick_rate_changed | crosses | Wire call |
Hooks::play_sound | crosses | Wire call |
Hooks::play_sound_source | crosses | Wire call |
Hooks::play_streaming_source | crosses | Wire call |
Hooks::sound_engine_load | crosses | Wire call |
Hooks::spread_players_teleport | crosses | Wire call |
Hooks::sprite_source | crosses | Wire call |
Hooks::submit_custom_geometry | crosses | Wire call |
Hooks::sweep_attack | crosses | Wire call |
Hooks::tags_updated | crosses | Wire call |
Hooks::teleport_command | crosses | Wire call |
Hooks::texture_atlas_stitched | crosses | Wire call |
Hooks::tick_target_millis | crosses | Wire call |
Hooks::toast_add | crosses | Wire call |
Hooks::use_item_on_block | crosses | Wire call |
Hooks::validate_tickets | crosses | Wire call |
Hooks::vanilla_game_event | crosses | Wire call |
Hooks::wind_burst | crosses | Wire call |
Hooks::window_resize | crosses | Wire call |
Hooks::xp_orb_targeting | crosses | Wire call |
ReloadApply::apply | crosses | Wire call |
StructureType::find_generation_point | crosses | Wire call |
StructureType::load_piece | crosses | Wire call |
StructureType::after_place | crosses | Wire call |
StructurePiece::bounding_box | crosses | Wire call |
StructurePiece::post_process | crosses | Wire call |
StructurePiece::add_additional_save_data | crosses | Wire call |
BlockPart::block_subscribed | crosses | Wire call |
BlockPart::neighbor_changed | crosses | Wire call |
BlockPart::random_tick | crosses | Wire call |
BlockPart::tick_block | crosses | Wire call |
BlockPart::update_shape | crosses | Wire call |
BlockPart::block_signal | crosses | Wire call |
BlockPart::is_signal_source | crosses | Wire call |
BlockPart::has_analog_output | crosses | Wire call |
BlockPart::analog_output | crosses | Wire call |
BlockPart::can_survive | crosses | Wire call |
BlockPart::may_place_on | crosses | Wire call |
BlockPart::is_valid_bonemeal_target | crosses | Wire call |
BlockPart::is_bonemeal_success | crosses | Wire call |
BlockPart::perform_bonemeal | crosses | Wire call |
BlockPart::state_for_placement | crosses | Wire call |
BlockPart::fire_odds | crosses | Wire call |
BlockPart::smoke_source | crosses | Wire call |
BlockPart::affect_neighbors_after_removal | crosses | Wire call |
BlockPart::block_jump_factor | crosses | Wire call |
BlockPart::block_speed_factor | crosses | Wire call |
BlockPart::fall_on | crosses | Wire call |
BlockPart::bounce_on | crosses | Wire call |
BlockPart::block_render_visibility | crosses | Wire call |
BlockPart::use_item_on | crosses | Wire call |
BlockPart::use_without_item | crosses | Wire call |
BlockPart::can_harvest_block | crosses | Wire call |
BlockPart::can_sustain_plant | crosses | Wire call |
BlockPart::clone_item_stack | crosses | Wire call |
BlockPart::connects_to | crosses | Wire call |
BlockPart::contents_changed | crosses | Wire call |
BlockPart::dispense_method | crosses | Wire call |
BlockPart::entity_inside | crosses | Wire call |
BlockPart::exp_drop | crosses | Wire call |
BlockPart::is_ladder | crosses | Wire call |
BlockPart::is_pathfindable | crosses | Wire call |
BlockPart::item_after_vertical_collision | crosses | Wire call |
BlockPart::minecart_pass | crosses | Wire call |
BlockPart::on_block_exploded | crosses | Wire call |
BlockPart::on_neighbor_change | crosses | Wire call |
BlockPart::remove_block | crosses | Wire call |
BlockPart::should_check_weak_power | crosses | Wire call |
BlockPart::should_redstone_wire_connect_to | crosses | Wire call |
BlockPart::weak_changes | crosses | Wire call |
BlockEntityPart::block_entity_subscribed | crosses | Wire call |
BlockEntityPart::block_entity | crosses | Wire call |
BlockEntityPart::block_entity_ticks | crosses | Wire call |
BlockEntityPart::block_entity_tick | crosses | Wire call |
BlockEntityPart::block_entity_update_tag | crosses | Wire call |
BlockEntityPart::screen | crosses | Wire call |
BlockEntityPart::screen_draw | crosses | Wire call |
BlockEntityPart::screen_click | crosses | Wire call |
BlockEntityPart::screen_tooltip | crosses | Wire call |
BlockEntityPart::menu_button | crosses | Wire call |
BlockEntityPart::block_entity_submit | crosses | Wire call |
BlockEntityPart::block_entity_render_bounding_box | crosses | Wire call |
BlockEntityPart::menu_quick_move | crosses | Wire call |
BlockEntityPart::block_entity_view_distance | crosses | Wire call |
BlockEntityPart::pre_remove_side_effects | crosses | Wire call |
BlockEntityPart::stateful_block_entity | crosses | Wire call |
ItemPart::item_subscribed | crosses | Wire call |
ItemPart::tooltip | crosses | Wire call |
ItemPart::use_on | crosses | Wire call |
ItemPart::use_item | crosses | Wire call |
ItemPart::hurt_enemy | crosses | Wire call |
ItemPart::inventory_tick | crosses | Wire call |
ItemPart::item_bar | crosses | Wire call |
ItemPart::dispense_item | crosses | Wire call |
ItemPart::tooltip_component | crosses | Wire call |
ItemPart::tooltip_component_draw | crosses | Wire call |
ItemPart::item_decorations | crosses | Wire call |
ItemPart::use_duration | crosses | Wire call |
ItemPart::use_animation | crosses | Wire call |
ItemPart::on_use_tick | crosses | Wire call |
ItemPart::release_using | crosses | Wire call |
ItemPart::can_perform_action | crosses | Wire call |
ItemPart::crafting_remainder | crosses | Wire call |
ItemPart::damage_item | crosses | Wire call |
ItemPart::on_left_click_entity | crosses | Wire call |
ItemPart::on_entity_swing | crosses | Wire call |
ItemPart::item_interact_mob | crosses | Wire call |
ItemPart::on_entity_item_update | crosses | Wire call |
ItemPart::on_item_use_first | crosses | Wire call |
MenuPart::menu_subscribed | crosses | Wire call |
MenuPart::menu_slot_rules | crosses | Wire call |
FluidPart::fluid_subscribed | crosses | Wire call |
FluidPart::fluid_rules | crosses | Wire call |
ServerReloadListener::prepare | crosses | Wire call |
GameTestFunction::run | crosses | Wire call |
ClientReloadListener::prepare | crosses | Wire call |
BiomeColorResolver::color | crosses | Wire call |
BlockTintSource::color | crosses | Wire call |
BlockTintSource::color_in_world | crosses | Wire call |
BlockTintSource::color_as_terrain_particle | crosses | Wire call |
BlockTintSource::relevant_properties | crosses | Wire call |
Interaction::decide | crosses | Wire call |
Interaction::fire | crosses | Wire call |
SectionGeometry::add | crosses | Wire call |
ParticleProvider::tick | crosses | Wire call |
EnchantmentDamageEffect::apply | crosses | Wire call |
CommandArgumentType::kind | crosses | Wire call |
CommandArgumentType::parse | crosses | Wire call |
CommandArgumentType::suggest | crosses | Wire call |
GroundStateProvider::state_at | crosses | Wire call |
PlaceProvider::stack | crosses | Wire call |
PlaceProvider::insert | crosses | Wire call |
PlaceProvider::extract | crosses | Wire call |
TooltipAppender::append | crosses | Wire call |
CriterionTest::test | crosses | Wire call |
ResourceHandler<ItemResource>::set_max_stack_size | crosses | Wire call |
ResourceHandler<ItemResource>::size | crosses | Wire call |
ResourceHandler<ItemResource>::resource | crosses | Wire call |
ResourceHandler<ItemResource>::amount | crosses | Wire call |
ResourceHandler<ItemResource>::capacity | crosses | Wire call |
ResourceHandler<ItemResource>::is_valid | crosses | Wire call |
ResourceHandler<ItemResource>::insert | crosses | Wire call |
ResourceHandler<ItemResource>::extract | crosses | Wire call |
ResourceHandler<ItemResource>::insert_any | crosses | Wire call |
ResourceHandler<ItemResource>::extract_any | crosses | Wire call |
BlockCapabilityProvider<dyn ResourceHandler<ItemResource>>::provide | crosses | Wire call |
EnergyHandler::amount | crosses | Wire call |
EnergyHandler::capacity | crosses | Wire call |
EnergyHandler::insert | crosses | Wire call |
EnergyHandler::extract | crosses | Wire call |
BlockCapabilityProvider<dyn EnergyHandler>::provide | crosses | Wire call |
ItemCapabilityProvider<dyn EnergyHandler>::provide | crosses | Wire call |
EntityCapabilityProvider<dyn ResourceHandler<ItemResource>, Option<Direction>>::provide | crosses | Wire call |
EntityCapabilityProvider<dyn EnergyHandler, Option<Direction>>::provide | crosses | Wire call |
ResourceHandler<FluidResource>::set_max_stack_size | crosses | Wire call |
ResourceHandler<FluidResource>::size | crosses | Wire call |
ResourceHandler<FluidResource>::resource | crosses | Wire call |
ResourceHandler<FluidResource>::amount | crosses | Wire call |
ResourceHandler<FluidResource>::capacity | crosses | Wire call |
ResourceHandler<FluidResource>::is_valid | crosses | Wire call |
ResourceHandler<FluidResource>::insert | crosses | Wire call |
ResourceHandler<FluidResource>::extract | crosses | Wire call |
ResourceHandler<FluidResource>::insert_any | crosses | Wire call |
ResourceHandler<FluidResource>::extract_any | crosses | Wire call |
EntityCapabilityProvider<dyn ResourceHandler<FluidResource>, Option<Direction>>::provide | crosses | Wire call |
EntityCapabilityProvider<dyn ResourceHandler<ItemResource>, ()>::provide | crosses | Wire call |
BlockCapabilityProvider<dyn ResourceHandler<FluidResource>>::provide | crosses | Wire call |
ItemCapabilityProvider<dyn ResourceHandler<FluidResource>>::provide | crosses | Wire call |
ItemCapabilityProvider<dyn ResourceHandler<ItemResource>>::provide | crosses | Wire call |
| Library mod plain pub fn / pub static called by a dependent | crosses | shared dependency rlib, stable crate identity, and retained public exports; production-loader library fixture covers separate library/observer/dependent builds |
Required gap total: 0.
Entity snapshot revision boundary
client_update_fields is on the wire but outside the entity revision counter (containers.rs:18, lib.rs:312, containers.rs:75). Any future revision-keyed cache must treat it as excluded from the revision guarantee and synchronize it independently. Stage 1b must clarify the ModRevision documentation's "Equal revisions, equal contents" statement accordingly.
Provider-query review: trust boundaries and native-side notes
The implementation at fdccc11a1d and exact-variant tests at 40b17368f1 were independently reviewed. Full structural reply decoding, including trailing-byte validation and static owner conversion, completes before any cursor writeback. The reviewer reproduced refusal without cursor mutation for incomplete cursors, invalid Option/bool tags and trailing bytes, and killed missing-writeback and late-validation mutants.
The stale provider-id and backwards / u32::MAX cursor probes forge the trusted HOST reply after the native encoder has returned. They do not establish hostile GUEST control of production provider-query replies. Native HostCodec creates ordinary Held ids by inserting their objects into its live generational table; return-to-origin erased values are resolved against the guest's typed handler table. Native invocation and release check incoming native Held ids. No HOST_VALIDATE request or additional per-resource round trip was added.
Cursor writeback remains structural u32 writeback, not a semantic table-bound proof. The current reply carries no native table length. Enforcing that upper bound needs new source-owned receipt machinery; classifying arbitrary mutable u32s as monotone cursors would change the generic scalar contract. That hardening was explicitly excluded from this change. Production InstalledProviders bounds its input cursor with native table access; the review found no guest-controlled override of that installed table. Do not interpret the host-forgery probes as a wasm coverage gap or as proof that arbitrary cursor values satisfy native enumeration semantics.
Native-side note, not a wasm gap: a buggy custom native CapabilityProviders implementation may return Some without advancing its cursor, causing the existing native ask_providers consumer to repeat indefinitely. The progress-check prototype e12d95289a is deliberately not merged, following the narrowed review scope. It needs a fresh review before any future integration; its once-only logging was not verified here.
Real Wasmtime group/provider behavior and budget exhaustion are separate integration checks, not established by the native loopback or forged-host transport probes.
Hook notes
native_block_clone_item supplies a block's clone stack from its actual state before ordinary Ctrl+Pick block-entity component copying. First response owns the result; no response retains vanilla. An empty or unregistered response suppresses picking. Bundles need "hooks": @API:block_clone_item@.
ServerHost::create_boss_bar(id, name) creates a custom boss event and returns Result<Option<BossBarRef>, TextFormError> (None when the id is taken or invalid, Err for text deeper than 512 nodes); ServerHost::boss_bar(id) finds an existing one. The handle sets its name, value, maximum, colour, overlay, flags and visibility, adds or removes PlayerRef viewers with add_player/remove_player, and deletes the event with remove. These are saved custom boss events, not a per-player bar snapshot. Bundles need "hooks": @API:boss_bar@.
Camera entity
ClientHost::camera_entity() returns an EntityRef for the entity the view follows
(Minecraft.getCameraEntity): the local player, unless a server attached the camera to another
entity. It is None on the title screen and outside a world. Together with camera() and
camera_type() it lets a minimap or freecam mod tell its own view from a spectated one.
ClientHost::set_camera_entity(entity) is Minecraft.setCameraEntity: it moves the view onto a
mob (or back onto the player with the player's own ref) and returns whether it did. It selects
the entity only; the camera angle stays the entity's, and camera_pose is the way to override it.
Client heights
ClientHost::client_surface_heights(columns, heightmap) is LevelReader.getHeight(type, x, z) on a
client level: each column's height from the chunks the server has sent this window, None for a
column it holds no chunk of. A minimap or a tracker reads many columns in one call instead of
scanning blocks. It is the client's own view, so it follows the server's block changes as they arrive.
Loaded chunks
LevelRef::loaded_chunks(host) lists the chunks the level holds now, the same ones chunk
finds by position, ordered by x then z so two calls over an unchanged level agree. It loads no
chunk and sorts a copy of the level's loaded set, so call it when a mod needs the whole set, not
for a position it already knows.
Client window and screen state
ClientHost::window_focused() reports the last focus state from the window
system. ClientHost::observed_screen_id() returns the same ID screen hooks see,
including overlays. None means no view is available: there may be no open
screen or no renderer yet. It does not mean the client is outside a world.
Read again after a screen or focus change.
Input preferences
ClientHost::input_options() returns a copy of the current mouse and movement
input preferences, even outside a world. Read again after settings change; an
older snapshot stays unchanged. The fields are documented in the API reference.
Option snapshots
ClientHost::graphics_options(), gui_options(), chat_options() and audio_options() each
return a copy of one group of the player's preferences, even outside a world, like
input_options(). Read again after the settings change; an older snapshot stays unchanged.
A zoom, brightness, HUD or chat mod reads these instead of keeping its own copy of the player's
choices. The fields are documented in the API reference.
Host::add_particle(&ParticleOptions, position, velocity) -> bool submits one particle to this client's loaded world. It preserves all option and velocity slots, including encoded color/size arguments, and uses the existing built-in and named-sprite providers with normal camera-distance, Particles-setting and population limits. True means submitted, not necessarily visible; missing providers/sprite sets draw nothing. It returns false on servers, without a client player/level, or for non-finite numeric input. A mod's own particle type gets code through register_particle_providers; new trigger hooks remain separate. Bundles need "hooks": @API:client_particles@.
Client session and local-player status
ClientHost::client_session() supplies read-only client_server(),
client_language() and client_frame_stats(). All return None outside a
logged-in client world. These reads use existing local state and send nothing.
ClientServer keeps missing names and addresses explicit: its name is the
integrated world's display name, or None for an external connection (the game
retains the player name, not a server display name). Its address is absent for
memory connections; friend-invite secrets are redacted. integrated_local
means this window actually owns that integrated server, not that the address
looks like localhost. A splitscreen guest does not own the server.
save_folder gives the owning window the integrated save's folder name as a
key for local data. It survives display-name changes and is scoped to the game
instance. It is absent for external connections and splitscreen guests.
client_language() reads the applied language, not a pending options choice.
Use the existing ClientHost::is_paused() for the applied pause state.
ClientFrameStats reports the game's most recently computed fps, lifetime
rendered_frames (menus included), and the newest completed frame's wall
interval as frame_time_ns (including waits). The interval is None until a
sample exists or after the chart is reset. Reading it starts no new recording.
PlayerRef::block_breaking(&dyn ClientHost) and ambient_mood(&dyn ClientHost)
only accept the current local player's handle. Stale, fake and nonlocal handles
return None, as does a disconnected client. BlockBreakingState::Idle is
instead a valid local player who is not breaking a block. Breaking gives the
target position, accumulated predicted progress and optional visible crack
stage (0 through 9; absent at zero progress). The stage uses the same
calculation as the world overlay. Mood is the existing cave ambience value,
normally 0 through 1, not a server-side measurement.
Sending a map image
PlayerRef::send_map sends a complete 128-by-128 map image through a
&mut dyn ServerHost. It validates the live player handle and refuses fake
players; a connectionless actor never produces a protocol packet. The image
uses map colour bytes and is not saved in the world's map storage. Send it
again after a player reconnects if no saved map owns that id.
Equipment slot
ItemResource::equippable_slot(host) answers which slot a stack goes in when worn
(Equippable.slot), from the item's equippable component after the stack's patch, so a
command's or a data pack's override counts. It does not say whether a given entity may wear it;
that depends on the wearer and is not answered here.
Enchantment conflicts
Host::enchantments_compatible(a, b) answers Enchantment.areCompatible from the world's
enchantment definitions, so a data pack's exclusive_set counts. A mod that adds an enchantment
to a stack asks it for each enchantment already on it, as the enchanting table and the anvil do.
Effective stack properties
ItemResource reads the stack's component overrides and removals before item defaults:
damageable(host), foil(host), rarity(host) and enchantable(host).
repairable(host, material) takes another resource; only its item identity and tags matter,
not its count or component patch. bar(host) returns an optional ItemBar, with width out of
13 pixels and RGB colour, before client drawing hooks; bundles use their fullness bar.
use_duration(host) returns ticks, including the gameplay use-duration Part. Vanilla's
current item classes do not require a player argument; Parts see the host's current acting
player. These reads use a world-phase host; tag-based repair checks need its authoritative world tags.
Duration and bar reads need an engine backend and return None for an unknown item.
Tooltip lines and random enchanting
ItemResource::tooltip(client, advanced, creative) returns styled text and line colours
for overlays that need the ordinary inventory tooltip. It uses the client's current viewer
and the requested flags. The base lines do not call mod listeners or lay out a tooltip, so
calling this from a tooltip listener does not dispatch that listener again.
ItemResource::enchant_randomly(server, level, power, pool) rolls from the specified
enchantment tag with the active server level's random source and the stack's effective
enchantability. It returns a detached result. Use the normal item-place or capability
transaction to replace a stored stack. Discarding the result does not rewind the random source.
Stack combat queries
ItemResource::entity_attack_range(host, attacker) returns effective minimum, maximum and
hitbox margin; contains(distance, buffer) uses vanilla's inclusive float bounds.
damage_source(host, attacker) returns a typed owned source: component override, item-specific
source (mace smash), then the actor's ordinary melee source. Actors and targets are typed handles;
unavailable or stale handles return None. These reads never equip the queried stack.
can_be_hurt_by(host, source) asks whether the item itself can take that source's damage,
including damage-resistant component overrides, removals and damage type tags. It does not
calculate the wearer's protection.
attack_damage_bonus(host, attacker, target, base_damage) previews enchantment and item bonuses
with a cloned RNG snapshot. A read never consumes world RNG; a later attack may roll differently.
hurt_and_break remains the durability write.
Bone meal at a position
LevelRef::bone_meal(host, pos) runs the whole bone meal sequence on one block for a mod that
grows things itself (a sprinkler, a growth totem), so it neither reimplements the checks nor
skips bonemeal listeners. It takes no stack: nothing is consumed, and a hook that edits the
stack it is shown edits a throwaway one.
Setting a biome
LevelRef::set_biome(host, pos, biome) changes one biome cell, the 4x4x4 block volume a biome is
stored in, so every position in that cell changes with it. It shares /fillbiome's save, mesh and
resend path, so the change survives a reload and reaches players as the command's does. Name a
biome the world knows (a data pack's included); anything else is refused.
World and spawnable bounds
LevelRef::in_world_bounds(host, pos) and in_spawnable_bounds(host, pos) answer
Level.isInWorldBounds and Level.isInSpawnableBounds without loading a chunk. The first also
needs the position inside the level's build height; the second is the wider limit /summon and
/tp use (30,000,000 on x and z, 20,000,000 on y, whatever the level's height). A mod checks them
before reading or writing a far position, rather than learning it from a failed write.
Biomes, sky and floor height
LevelRef::biome_at(host, pos) reads an active or parked server level's biome;
client_biome(host, pos) reads the client's current copy. Both use the level's biome zoom,
including its hashed seed, and return None when the selected noise column is not loaded.
They do not generate or load chunks. A biome read may select a neighbouring column.
find_closest_biome(host, biome, BiomeSearch::new(origin, radius, horizontal_step, vertical_step))
uses the server generator's sampled search, as /locate biome does. biome is an id or #tag.
The result names the biome and its sample position. This searches generated biomes, not later
edits to resident biome cells, and does not load resident chunks. A negative radius or
nonpositive sampling step returns None.
get_sky_darken(host) shares gameplay's dimension, time and weather calculation.
logical_height(host) is the dimension's logical height, which can differ from build height.
block_floor_height(host, pos) uses normal collision shapes with an empty entity context;
the height is relative to the block, and negative infinity means no floor. An unloaded
column or an out-of-build-height position returns None. These three reads also work on
parked server levels and the current client level, without loading chunks or swapping dimensions.
Respawn data, maps, ticks, sky and sight
LevelRef::respawn_data(host) is the world spawn and the way a player appearing there faces
(LevelData.getRespawnData), and set_respawn_data(host, pos, yaw, pitch) is /setworldspawn's
own write: the yaw wraps and the pitch clamps as RespawnData.of does, a player whose respawn
point was the old world spawn follows it, and spawn_change fires. Horizon keeps one world spawn,
in the Overworld, so only the Overworld's handle writes it.
map_data(host, id) is a map's saved data (MapItemSavedData): centre, scale, dimension, the
locked, tracking and unlimited flags and its 128 by 128 colour ids, from memory or from its
.dat without loading it for good. set_map_data(host, id, colors) repaints it pixel by pixel as
setColor does, so the map saves, redraws and reaches the players carrying or framing it. Maps
belong to the world: any current level handle reaches them. Markers are not part of either.
block_tick(host, pos) runs the block's scheduled tick now through the path a due tick from the
queue takes (the block's part first, then BlockState.tick), and block_random_tick(host, pos)
random-ticks the position as tickChunk does, lava's fluid tick included. Both draw from the
level random and send their level events exactly as the normal ticks do; neither reads or changes
the tick queue, which schedule_block_tick fills.
can_see_sky(host, pos) is canSeeSky: full sky light. line_of_sight(host, from, to) is the
clip LivingEntity.hasLineOfSight makes between two eyes, collider shapes and no fluids.
lightning_target(host, pos) is findLightningTargetAround: a rod, else a living entity under
open sky near the column picked with the level random, else the column's top. It strikes nothing.
Environment attributes are not here: Horizon has no general server-side attribute table to read.
Drops, fluid shapes, collisions and entity rays
LevelRef::block_drops(host, pos, tool, entity) is Block.getDrops: what the block at pos
would drop if entity broke it with tool, with what its block entity copies in (a shulker
box's contents, a banner's patterns, a pot's sherds). It breaks nothing and spawns nothing, and
fires no block_drops event; destroy_block with collect does all of that. The roll draws
from the table's random sequence or the level random, as vanilla's does, so it takes a
ServerHost and moves later draws.
LevelRef::roll_loot(host, table, origin, tool, entity) is LootTable.getRandomItems: one roll
of any loot table, the world's data packs merged, handed back instead of dropped, for a sieve or a
machine that turns an input into a table's roll. The context is the one /loot … fish <table> <pos> <tool> gives: origin, tool for match_tool and enchantment bonuses, and entity as
this. The stacks come split to their stack sizes. It draws as block_drops does and fires no
loot_drops event. None for a table that isn't there.
fluid_shape(host, pos) is FluidState.getShape: no box for no fluid, else one block-local box
up to the fluid's height, a full block under the same fluid. has_collisions(host, aabb) asks
whether any block collision shape is in a box, with collision_boxes's walk and limits and
without building the list; entities and the world border are not part of it.
ray_trace_entities(host, from, to, margin, exclude) lists every entity the segment passes
through, nearest first, with where it enters each box grown by margin and how far that is:
the test ProjectileUtil.getEntityHitResult makes on each candidate. Vanilla's filter is a
predicate (canHitEntity, isPickable); here it is the caller's, who takes the first hit it
accepts, for example the first living entity. A ray starting inside a box does not hit it, so
pass the shooter as exclude or start outside it. Candidates are the entities entities_in
finds, so spectators are never hit.
Fluid properties and the explicit fluid tick
The fluid queries answer for a mod's world fluid as they do for water and lava, through the same rules the flow reads, so a custom fluid behaves like one of them without a table of its own.
Position-dependent answers are LevelRef reads. fluid_tick_delay(host, fluid, pos) is
Fluid.getTickDelay: water 5, lava 30 and 10 in the Nether, the empty fluid 0, a flowing form as its
source; a mod's fluid answers what it declared as its fluid_rules parts change it at pos. It reads
the level in place on the server and is None for a position that isn't loaded. fluid_height(host, pos) is FluidState.getHeight (a full block under the same fluid, else amount / 9),
fluid_height_for_camera is the surface the camera's immersion test reads (a source under a block
with a sturdy underside is 1), and fluid_can_be_replaced_with(host, pos, fluid, direction) is
FluidState.canBeReplacedWith: water yields to a non-water fluid flowing down, lava to water from 4/9
of a block high, the empty fluid to anything and a mod's fluid to nothing.
World-independent facts are in the fluid service (host.fluid_states()): type_properties(key) now
also carries explosion_resistance (100 for a fluid, 0 for the empty one), randomly_ticks (lava
only) and drip_particle (minecraft:dripping_water, minecraft:dripping_lava, else none), and
legacy_block(state) is FluidState.createLegacyBlock, the water, lava or mod fluid block that holds
a fluid state, air for the empty fluid.
fluid_tick(host, pos) takes a ServerHost and runs the flow step of the fluid at pos now, the
body of a due fluid tick: it levels, spreads and schedules its next step with the same random draws,
hooks and level events. Like block_tick it neither reads nor changes the tick queue; schedule_fluid_tick
queues one.
Players, structures and chunk watchers
LevelRef::online_players(host) is ServerLevel.players: the online players in the level, parked or in
place, each once under their own UUID, also while a guest's turn is running. Fake players are not
listed, nor is a player a nested guest turn displaced, whose handle resolves again when that turn
ends. entities_in stays the one area query: it finds players in a box too.
find_nearest_structure(host, structure, StructureSearch::new(origin, radius)) is
ServerLevel.findNearestMapStructure without skipping known starts, the search /locate structure runs in the level's generator. structure is an id or #tag; the result names the
structure and the position /locate reports. radius counts rings of placement cells for
random-spread sets, from 0 to StructureSearch::MAX_RADIUS (100, /locate's); strongholds' rings
are all checked whatever it is. The search builds structure starts but neither loads nor generates
chunks; a world without structures, a structure this level cannot generate, an origin past the
world's 30,000,000-block edge and a radius out of range give None.
ChunkRef::watchers(host) is ChunkMap.getPlayers(pos, false): the players the chunk was sent to
and is inside the view of, from their chunk_watch until they move away from it. Horizon's
far-ring columns past the view distance are not watched, and fake players watch nothing.
native_muffle_sounds(amount, lowpass, ticks, exempt) (host.muffle_sounds) turns every sound down and low-passes it, fading back over ticks client ticks, except the sound events in exempt. Bundles need "hooks": @API:muffle_sounds@.
Named hook catalog (draft)
The declarations under crates/horizon-mod-api/src/hooks/ map stable hook names and contract versions to the current
source targets. A contract includes the signature, call timing and behavior.
Keep an existing contract or add a new version when it changes incompatibly.
The catalog does not cover helper methods or arbitrary private game code.
Injections name them with hook and hook_version ("Named hook points" above).
Reading server blocks (draft)
A native server tick hook can call native_server_block with its context, a dimension id and a block position. It returns the block name and state properties from a loaded chunk. It does not load a chunk or open a dimension. Reads outside that hook, from a stale context, from unavailable dimensions or outside the world bounds return an error.
Neighbour observations (draft)
native_neighbor_changed observes each authoritative server neighborChanged before the block handles it. It supplies the dimension, position, current owned BlockState, changed BlockKey and moved flag. The changed block key comes from the source block's default state through the prepared state index, after the primary state's routing guard. No registry-name lookup or allocation occurs on this path. Use explicit immutable block metadata for property queries; the hook does not load chunks or change the update queue. Its host is the level with its ticks (&mut dyn LevelTicksHost), as vanilla's neighborChanged gets the level: a BlockPart::neighbor_changed can read its inputs there with level.signal(pos, direction), direct_signal, direct_signal_to, best_neighbor_signal and has_neighbor_signal, the same reads as LevelRef's (mods' parts included), and schedule its own tick with level.schedule_block_tick, as a diode does (DiodeBlock.checkTickOnNeighbor). The reads write nothing and draw nothing, so they leave the update as it was, and cost what the game's own call does. tick_block and the other hooks with a LevelWriteHost have them too. Contract version 2: version 1 handed the hook a plain &dyn LevelHost. Diverges from upstream.
Server block changes (draft)
native_block_changed observes a committed authoritative server Level.setBlock change before block side effects and neighbour updates. It supplies the dimension, position and old and new state IDs. It does not observe chunk loading or direct storage writes that bypass Level.setBlock.
Server commands and players
For mods that run where the world is simulated: a dedicated server, a LAN host, single player's server thread
(crates/horizon-client/src/game/modding/native_server.rs). The SMP mod of the official server is built on them
(smp.md).
-
native_register_commands(commands): push aCommandper usage, asked once when a world opens to players. A usage is words, then arguments<name:kind>, optional ones last in brackets:Command::new("pay <player:player> <amount:integer>"),Command::op("eco set <player:player> <amount:integer>", 3)for operators of level 3. Kinds:word,text(the rest of the line, last),integer,number,boolean,player,item,block_pos, and any command argument type a mod registered (Registrar::register_command_argument): its namespaced id is the kind, as in<machine:mymod:machine>. A custom argument is one word (or the rest of the line when its type saysGreedy) and delivers its type's canonical value inrun_mod_command. The usages of one command merge into one tree, which every player receives after vanilla's (filtered by their level, as vanilla's is), so their chat box completes and colours it. A client that does not know a custom type still parses and completes it: the tree sends the argument as a Brigadier string and asks the server for its suggestions. Fabric sends the registered argument type's id instead, which an unmodded client cannot read; the string keeps its tree valid. The server also parses player and console invocations against that tree, including each usage's permission and argument types, before calling a mod. Each executable usage keeps its own permission: an operator-onlyfooremains unavailable to ordinary players even iffoo baris public. Players can traverse the sharedfooprefix to reachbar, but cannot executefooitself. Optional arguments make their preceding prefixes executable at that usage's level; if several usages execute at the same prefix, its lowest executable usage level wins. A rejected usage does not change the permissions of accepted usages. When argument alternatives consume the same input without errors, an executable alternative takes precedence over a denied one; equally valid alternatives keep registration order. A name vanilla has, or a usage that doesn't parse, is left out with a warning in the log. -
run_mod_command:event.commandis an ownedCommandInvocationwith its literal path and namedCommandArgumentValuevalues, not raw command text. Integer, number and boolean values retain the dispatcher's types; word and greedy text are strings. Player selectors resolve to a single livePlayerRef, items become component-bearing stacks of one, and block positions resolve source-relative/local coordinates without requiring loaded chunks. A custom argument isCommandArgumentValue::Custom { kind, value }, wherekindis its registered type's id andvaluewhat its parser returned. Missing optional arguments are absent. Parsing, permission and resolution failures produce feedback without calling listeners. The console resolves from the Overworld spawn's block corner with zero rotation and no source entity (@sfails). ReturnFlow::Cancelwhen handled. -
native_armor_stand_manipulate(player, name, stand_id, slot, cancel): a player takes an item from an armor stand or puts one on it, heard after the stand's disabled-slot checks and before anything moves.slotismainhand,offhand,feet,legs,chestorhead. Setcanceland nothing changes (the click is spent). The player's state is swapped in;playeris their UUID. Not told of a click that changes nothing; breaking or blasting a stand and a dispenser armouring one are other ways its equipment moves and are not covered. -
native_vehicle_moved(player, kind, from, to): a boat or minecart that carries a server player (player, its UUID) moved since the last tick, heard once a tick after the move with its positions before and after. The rider's state is swapped in, sohost.players().acting_player,PlayerRef::telland the item helpers reach them. A vehicle a player rode and left is still told of, with noplayerand nobody swapped in, until it has stood still for ten seconds. Mounts (horses...) and vehicles nobody ever rode are not covered. It only hears and costs one position compare a tick for each watched vehicle. -
native_container_opened(player, name, kind, pos, cancel): a player is about to open one of the game's own menus (a chest, furnace, crafting table, villager, anvil, lectern, a horse's inventory...).kindis the menu type (minecraft:generic_9x3,minecraft:merchant...),posthe block it belongs to, none for a villager, a mount or a vehicle's chest. Heard after the checks that refuse the menu (a locked chest), before the open menu closes. Setcanceland nothing opens. The player's state is swapped in, sohost.players().acting_player,PlayerRef::telland the item helpers reach them. Menus a mod opens (native_open_menu) are not heard. -
native_creative_slot(player, name, slot, item, count, cancel): a creative-mode player sets a slot from the creative screen (slotis the inventory menu's slot; below 0 is a stack thrown out of the screen), with theitem(minecraft:airfor an emptied slot) andcount. Heard after vanilla's checks, before the slot changes. Setcanceland the packet is ignored. The player's state is swapped in. Stacks a creative player gets other ways (pick block,/give) are not heard. -
Hooks::player_game_mode_changed: a server player's game mode is about to change, fromevent.fromtoevent.to, bothGameMode(Survival,Creative,Adventure,Spectator): the/gamemodecommand, the F3+F4 switcher's packet and a hardcore world's respawn into spectator, once per change to a different mode. The mode a player joins with (defaultgamemode) is not heard. ReturnFlow::Canceland the player keeps their mode. Inside the command and the switcher the player's state is swapped in; for the hardcore respawn it is not. The hook receives a&mut dyn ServerHost, andevent.playeridentifies the player by UUID. -
native_player_changed_dimension(player, name, from, to): a player changed dimension, once per move (fromandtoare ids such asminecraft:overworldandminecraft:the_nether), heard when the move has ended: the portal's exit is found and the player stands there, a command's teleport has landed, or a respawn in another level than the death's is placed for good.tois the level in place and the player's state is swapped in (as innative_run_mod_command), sohost.players().acting_playerandPlayerRef::tellreach them. Heard for Nether and End portals (also riding, and leaving the End as its winner),/execute in ... run tpand any command teleporting into another level, and the death respawn. Not heard: a move within a level (/tp, an End gateway), a player who leaves before the move ends, a respawn in the dimension of the death, and anything outside a dedicated server. -
native_player_toggle_flight(player, name, flying, cancel): a player's client started (flying) or stopped flying, once per change, for a player who may fly (creative or spectator), on a server. The player's state is swapped in, soPlayerRef::telland the other helpers reach them. Setcancelto refuse it: the player keeps the state they had and gets their abilities back, so the client lands or flies again. Only what a client asks for is heard: not the flight a game-mode change starts or ends, and not a LAN host's own player. -
native_player_switch_hotbar_slot_pre(player, player_entity_id, old_slot, new_slot, cancel)andnative_player_switch_hotbar_slot_post(player, player_entity_id, old_slot, new_slot)(PlayerSwitchHotbarSlotEvent.Pre/.Post): a server player switches to a different hotbar slot. Pre runs after the slot range check and before it changes; setcancelto keep the old slot, and the client is sent that slot again. Post runs after the change. The player's state is not swapped in. -
native_furnace_burn(pos, fuel): a furnace consumedfueland started burning atpos. -
native_furnace_smelt(pos, item, result): a furnace finished smeltingitemintoresultatpos. -
native_player_item_cooldown(player, name, item, ticks, cancel): an item cooldown is about to start for a server player, including a used item's cooldown and a shield disabled by a hit.itemis the item id andticksits duration. Setcancelto skip the cooldown; shield disable still stops the use and plays its configured sound. The player's state is not swapped in. -
native_player_recipe_discovered(player, name, recipe, cancel): a recipe id is about to become known to a server player through an advancement reward, a knowledge book or/recipe give. Setcancelto keep it locked; the client is sent the matching removal. The player's state is not swapped in. -
native_player_set_spawn(player, player_entity_id, name, dimension, pos, forced, cancel): a server player's respawn point is about to be set, at eachServerPlayer.setRespawnPositioncall: every sleep in a bed, a new respawn anchor point,/spawnpoint, and the copy to a respawned player (posnone for a player with no point). Setcancelto keep the old point. -
native_entity_travel_to_dimension(entity, dimension, cause, cancel): a server entity is about to travel to the leveldimension, before it moves (NeoForge'sEntityTravelToDimensionEvent);causeisnether_portal,end_portal,end_gatewayorteleport(a/tpor spectator teleport into another level). Setcancelto keep it where it is. Players and every other entity that crosses a portal or a command's teleport are asked; passengers are asked recursively, and a refused passenger stays behind, dismounted, with its own subtree. Discarding an entity in the callback prevents its travel. The player's state is not swapped in. -
native_block_damage_abort(player, name, pos): a server player stopped diggingposbefore it broke. It is heard after the server clears the dig and sends the block state back to the client. The player's state is not swapped in. -
native_player_pick_item(player, name, from_entity, pos, entity_id, cancel): a server player's middle-click pick resolved a block or entity's pick stack and is about to put it in the hotbar. For a block,posis its position andentity_idis zero; for an entity,from_entityis true,posis[0, 0, 0]andentity_idis its network id. The player's state is swapped in. Setcancelto leave the inventory and client unchanged. A pick with no result is not heard. -
native_sign_changed(player, name, dimension, pos, front, lines, cancel): a player finished a sign they were editing, heard once every check has passed (editing that sign, in reach, not waxed, the chat filter) and right before the text goes on it.linesholds the four lines as written, formatting codes still in; rewrite them and that is what the sign gets (the chat filter does not look at it again), or setcanceland it keeps its old text. The player's state is swapped in, soPlayerRef::tellreaches them. Not told: the host's own editing on a LAN or single-player world, and changes that are no text edit (dye, glow ink, wax,/data,/setblock). -
native_player_client_loaded_world(player, name): the server heard that a guest's client finished loading the level around them after joining, respawning or changing dimension. The player's state is not swapped in. -
native_player_stop_using_item(player, name): a server player released the use-item key, or a mod'srelease_using_itemdid, before the item finished being used. The player's state is swapped in. -
native_player_recipe_book_settings_change(player, name, book, open, filtering): a server player's recipe book open or filter setting changed;bookis the book type's protocol id. The player's state is not swapped in. -
native_player_command(player, name, command, canceled, replies): any command a player typed (without the slash), vanilla's too and commands the player may not run, heard before anything looks at it, and each line of a quiet command batch. Setcanceledand nothing runs; push lines torepliesand the player gets them as system chat (a quiet batch drops them). The player's state is not swapped in, andPlayerRef::telland the other helpers that take a UUID reach nobody inside this hook: answer throughreplies. Commands may carry secrets (/msg, a login plugin's password), so don't log them carelessly. -
native_run_mod_command(source, command, ran): executes a registered command with typed origin and permission. Feedback honours silence and track-output; setranwhen handled, otherwise execution fails. -
native_player_respawned(player, name): a dead player came back, once per respawn, on a server: afterPlayerList.respawnreset their health, food and experience and placed them at their bed, anchor or the world spawn (NeoForge'sPlayerRespawnEvent). The player's state is swapped in for the call, as for their other actions, sonative_playerand the other helpers reach them. A respawn at an anchor in another dimension, or at the world spawn's free column, may still move them on a later tick, once that level has loaded round them; the End-exit respawn (after the credits) does not fire it. -
native_player_login(player, name, cancel, reason): a player the server has accepted (their name, the whitelist, bans and the player limit are checked) and who holds a place, heard before their saved state is loaded and before they are placed in a level (NeoForge'sPlayerNegotiationEvent). Setcanceland they are disconnected withreason(a plain "Disconnected" when it staysNone); no join message and nonative_player_logged_infollow. It runs after the client's configuration, so the client sees the refusal as an in-game disconnect screen. The player is not placed yet, so no player's state is swapped in andPlayerRef::tellreaches nobody: the refusal text is the answer. Players the server refuses itself (name, whitelist, bans, a full server, the proxy's forwarding) are not heard. -
native_player_teleported(player, name, from, to, dimension): a server player a command (/tp,/teleport, also what a mod'srun_commandreaches) moved, inside their level or into another;fromandtoare their feet before and after anddimensionthe level they are in now. Heard after the move has reached the player's state and their client (into another level: when that level is entered, before it has loaded around them), so it cannot stop it (NeoForge'sEntityTeleportEvent). The player's state is swapped in, soPlayerRef::telland the acting-player helpers work. Ender pearls and chorus fruit have their own hooks below; not heard yet: a spectator's teleport menu; portals and respawns are not teleports here. -
native_ender_pearl_teleport(owner, pearl, from, target, damage, cancel)(version 2): a server player's or mob's pearl landed and is about to teleport its liveEntityRefowner.targetmay be moved; cancellation spends the pearl without teleport, sound, damage or endermite roll. Players retain acting-player context and mutable fall damage (5). Mobs preserve rotation, reset fall distance, take no player fall damage and make no endermite draw. The pending move refuses an owner changed by the listener. -
native_item_consumption_teleport(consumer, consumed, from, target, cancel)(version 2): a server player or fox whoseteleport_randomlyconsume effect (chorus fruit) is about to try a spot (NeoForge'sEntityTeleportEvent.ItemConsumption,TeleportRandomlyConsumeEffect.apply): once per attempt, up to 16, after they stopped riding. A mod may movetarget(its y is kept inside the level's height); the game still looks down from it for ground to land on, inside the border. Canceling ends the effect: no teleport and no more attempts, but the food is consumed.consumeris aLivingRefandconsumedis the captured stack before consumption (count and components included). A fox's listener receives the full server host before its living movement and before the next mob ticks; writes to actors or the level survive the continuation. Changing the consumer's mouth item, position or eligibility refuses the captured effect. -
native_player_clone(player, name, was_death): a player's data was copied to their new player (NeoForge'sPlayerEvent.Clone, fromServerPlayer.restoreFrom): after a death respawn (was_deathtrue) and when a player leaves the End after the credits (was_deathfalse, vanilla'skeepAllPlayerData). It is heard after the copy and before the new player is placed, once each, on a server. The uuid stays the same, soplayernames the old and the new player alike; no player's state is swapped in, so the helpers that look a player up may find nothing. It cannot stop anything. -
native_player_load_from_file(player, name, directory): a joining player's saved state was read and applied (NeoForge'sPlayerEvent.LoadFromFile,PlayerList.loadPlayerData), before they are placed in a level: once for each join on a server, for an integrated server's owner (loaded from the filelevel.datnames, else their own) as for anyone else.directoryis the world'splayers/datafolder, created if missing; read your own file there, named<uuid>.<suffix>. It is heard whether or not the player was in the world before (a new player has no file). No player's state is swapped in. It cannot change or stop anything. -
native_player_save_to_file(player, name, directory): a placed player's data was saved (NeoForge'sPlayerEvent.SaveToFile,PlayerList.save): when they leave or the world closes, when the dedicated server saves everyone, and for an integrated server's owner when the world saves their state intolevel.dat.directoryis the world'splayers/datafolder, created if missing; write your own file there, named<uuid>.<suffix>, and never the player's.dat. The player may be leaving, so no player's state is swapped in. It cannot change or stop anything. -
native_player_commands_sent(player, name, names): the command tree a player is sent, when they join and when their level changes (/op,/deop, the LAN settings).namesare the root commands their client would get, vanilla's they may use and the mods'; remove a name and that client's tree lacks it, so its chat box neither completes nor colours it. This hides the command from the client only, a typed one still runs (stop it withnative_player_command). For a player already on, their state is swapped in andPlayerRef::tellworks; a joining player has no world yet, so nothing can be told them. The tree the hub re-sends by itself (after a respawn or a change of level) reuses the last answer. -
native_player_sprint(player, name, sprinting): a server player's client started or stopped sprinting (the sprint key, a double tap forward, letting go), once for each change, after the server's player took it. It only hears. It reports what the client asks, not what the server does: a sprint that ends by itself (a hit's knockback, hunger, a wall) is not heard. The player's state is swapped in, soPlayerRef::telland the acting-player helpers work. -
native_resource_pack_status(player, name, status): a server player's client answered a resource pack while playing (ServerboundResourcePackPacket):statusisAction's number (0 loaded, 1 declined, 2 failed download, 3 accepted, 4 downloaded, 5 invalid URL, 6 failed reload, 7 discarded). It only hears. The pack a dedicated server offers during the configuration is answered before the player has joined and is not heard. The player's state is swapped in, soPlayerRef::tellworks. -
native_projectile_hit(owner, kind, hit_entity, pos, cancel): a projectile a server player launched (arrow, snowball, egg, thrown trident, ender pearl, ...) hit an entity (hit_entity) or a block atpos, heard before any of its effects (NeoForge'sProjectileImpactEvent). Setcanceland it does nothing: it vanishes, a thrown trident flies on through. The shooter's state is swapped in, soPlayerRef::tellanswers them. Mobs' and the host's projectiles are not heard, nor are fishing bobbers and firework rockets. -
native_tnt_primed(player, pos, cancel): TNT atposis about to be lit by a flint and steel or fire charge (playeris whoever clicked, whose state is swapped in), fire, a redstone signal, an arrow or a dispenser (playeris None). Setcanceland it is not primed: the block stays, or is put back if fire or a signal had already taken it out. TNT set off by a blast, a TNT minecart and a sulfur cube are not asked;native_explosion_detonatecan spare the blocks of a blast. -
native_hanging_place(player, name, kind, pos, cancel): a server player places an item frame, a glow item frame, a painting or a leash knot (tying a lead to a fence that has none;posis the fence), once it fits and before it is placed or the item used. Setcanceland nothing happens; the item stays in the hand. The player's state is swapped in, soPlayerRef::tellanswers them./summonand a lead tied to a mob are not heard. -
native_hanging_break(player, kind, pos, cancel): an item frame, a glow item frame or a painting (kind) hanging inposis about to break: a server player's hit (player, whose state is swapped in), a blast, or the wall it hangs on gone (playerNone for both), before anything drops or sounds. Setcanceland it stays as it was. A hit that only takes a frame's item out, the game's own player and/killare not asked. -
native_bucket_empty(player, name, pos, fluid, cancel): a server player empties a bucket (fluidisminecraft:water,minecraft:lavaorminecraft:powder_snow; fish and axolotl buckets pour water) intopos, right before the fluid is placed. Setcanceland nothing is poured. The player's state is swapped in, soPlayerRef::tellanswers them.native_fill_buckethears the same click without the player and the fluid; dispensers and cauldrons are not heard. A fluid or mob bucket is also heard at the block beside the clicked face whenever that block is notpos, since a block that refuses the fluid passes it there; the fluid may still go intopos. -
native_player_bed_enter(player, name, pos, cancel): a server player passed the sleep checks and is about to enter the bed at its head position. Setcancelto keep them out. -
native_player_bed_leave(player, name, pos): A server player woke or stood up from a bed, after their sleeping state changed. The head position ispos. -
native_player_edit_book(player, name, pages, signing, cancel): A server player book-and-quill edit or signing packet is about to be applied.pagesis its page count andsigningsays it has a title. Setcancelto drop the packet. -
native_player_shear_entity(player, name, entity_id, off_hand, cancel): A server player is about to shear a sheep, mooshroom, snow golem or bogged. The hook gives the mob entity id and whether the shears are in the off hand. Setcancelto leave the mob and shears unchanged. -
native_player_advancement_done(player, name, advancement): A server player completed an advancement after its progress was recorded.advancementis its identifier, such asminecraft:story/mine_stone. -
player_advancement_progress: A criterion of a server player's advancement was granted or revoked, after its progress changed and before any completion rewards, including theminecraft:tickcriteria granted on a network player's first tick or a reload. The server-side event suppliesplayer(UUID),player_entity_id,name,advancement, the changedcriterion, andprogress_type(ProgressType::GrantorProgressType::Revoke). Itsprogressis anAdvancementProgresssnapshot of this player's complete state at that mutation: all criteria in definition order with their original grant dates (orNone), the requirement groups, the number of satisfied groups, anddone. Each group is an OR of criteria; all groups must be satisfied, and an empty requirements list is not done. Clone the snapshot to retain it without borrowing the game; subsequent grants, revokes and reloads cannot change it. This is a read-only observation, not a way to write progress. A grant or revoke that changes nothing is not heard. The snapshot is built only when a listener subscribes. -
native_player_stat_award(player, name, stat_type, stat, value, cancel): A statistic is about to be awarded to a server player, before its count and scoreboard criteria change.stat_typeandstatare ids such asminecraft:minedandminecraft:stone, orminecraft:customandminecraft:walk_one_cm, andvalueis the amount. Setcancelto award nothing. -
native_player_riptide(player, name): a server player launched with a riptide trident, after its velocity and spin are applied. The player's state is swapped in. -
native_player_fish(player, name, state, cancel): a player's fishing rod, on a server:state0 the cast, 1 a fish biting, 2 the reel-in of a bite (the catch), 3 the reel-in with nothing on the line, 4 the reel-in of a bobber hooked to an entity or item. Setcanceland nothing is thrown (0), the fish gets away and the wait starts over (1), or the bobber stays out with no wear on the rod (2 to 4). The bobber ending by itself (too far, rod put away, owner dead) is not heard, nor is the host of a LAN world's rod. The player's state is swapped in, sohost.players().acting_player,PlayerRef::telland the item helpers reach them. -
native_block_dispense_armor(dimension, pos, item, target): a dispenser equippeditemon the first eligible player, mob or armor stand in front of its block atpos. The equip, item consumption, sound and game event have completed;targetnames the kind of entity equipped. -
native_block_shear_entity(dimension, pos, entity, kind): a dispenser sheared the entity ofkindand identityat the block in front of the dispenser atpos, after applying the shearing effects. -
native_block_receive_game(dimension, pos, block, event, cancel): a sculk sensor, calibrated sensor or shrieker received the vibrationevent. Setcancelto suppress its response. -
native_brewing_start(pos, ingredient): a brewing stand atposstarts its timer foringredient, after it consumes fuel. Server-side. -
native_campfire_start(pos, item):itemwas placed on the campfire atposand began cooking. Server-side. -
native_block_cook(pos, item, result): a campfire finishes cookingitemintoresultatpos, before the result is dropped. Server-side. -
native_compost_item(pos, item): a player adds a compostable item to the composter atpos, after its layer is applied. Server-side. -
native_crafter_craft(pos, recipe): a crafter completedrecipeatpos, after consuming its ingredients and handling its output. Server-side. -
native_player_attack_cooldown_reset(player, name): a server player's punch was handled: the main-hand swing and attack-strength reset have both happened. The player's state is not swapped in. -
native_player_bucket_entity(player, name, entity_id, entity): a server player caught a bucketable mob after the mob was removed and the filled bucket was put in the player's hand or inventory.entityis the mob species id. -
native_player_change_beacon_effect(player, name, pos, primary, secondary): a server player's beacon selection was stored. Effect values areOption<EffectKey>,Nonewhen none is selected. -
native_player_deep_sleep(player, name): a server player reached 100 ticks asleep in a bed and now counts as deeply asleep for the night-skip check. -
native_player_egg_throw(player, name): a server player threw an egg after its projectile was launched and its use applied. -
native_player_elytra_boost(player, name): a server player used a firework rocket to boost while gliding, after the rocket was launched and consumed. -
native_player_flower_pot_manipulate(player, name, pos, plant): a server player putplantinto the flower pot atpos, after its state changed and the item was consumed. -
native_player_insert_lectern_book(player, name, pos, book): a server player putbookon the lectern atpos, after the block entity and inventory changed. -
native_player_take_lectern_book(player, name, pos, book): a server player removedbookfrom the lectern atpos, after its block entity changed. -
native_player_lectern_page_change(player, name, pos, page): a server player changed the lectern atposto zero-basedpage, after its page and redstone pulse changed. -
native_player_item_frame_change(player, name, entity_id, action, item, rotation): a server player changed a frame (put,rotate,remove_itemorbreak), after its contents and comparator output changed.itemis empty when none remains. -
native_player_item_mend(player, name, item, repair, experience): an orb actually repairedrepairdurability onitemfor a server player, after the item changed.experienceis the orb value. -
native_player_trade(player, name, entity_id): a server player completed a villager or wandering trader trade, after the merchant applied it.entity_idis the merchant network id. -
native_trade_with_villager(merchant, kind, offer): NeoForge'sTradeWithVillagerEvent, heard once per trade a server player makes with a villager or a wandering trader (a shift-click that trades several times is heard for each), after the trade's statistic andvillager_tradetrigger.merchantis the trader's entity id,kindits type (minecraft:villagerorminecraft:wandering_trader), andoffertheMerchantOfferas it was right after the trade: its costs,result,uses(this trade counted),max_usesandxp. Listen only, heard once the whole click is done (its payments taken). The player's state is swapped in, sohost.players().acting_playernames them. Unlikenative_player_trade, which is heard once per click with no offer.MerchantRefoperations that conflict with an active menu operation are refused. -
native_player_shield_disable(player, name, ticks): a blocked melee hit disabled the server player’s shield forticksticks. It fires just before the cooldown is set. -
native_player_loom_pattern_select(player, name, pattern): a server player selected loom pattern buttonpattern, after the menu accepted it. -
native_player_name_entity(player, name, entity_id, entity_name): a server player used a named name tag on a mob, after it became persistent and was named. -
native_player_open_sign(player, name, pos, front): a server player opened the sign editor atposfor the front or back side, after the editor state was made. -
native_player_pickup_arrow(player, name, entity_id, item): a server player picked up an arrow after it was removed and its pickup was applied.itemis the pickup item id. -
native_player_stonecutter_recipe_select(player, name, recipe): a server player selected stonecutter recipe buttonrecipe, after the menu accepted it. -
native_player_swap_with_equipment_slot(player, name, slot): a server player swapped their main hand withslot(currentlyoffhand), after both hands changed. -
native_player_toggle_entity_age_lock(player, name, entity_id, locked): a server player used a golden dandelion on an ageable mob, after its age lock changed and the item was used. -
native_player_unleash_entity(player, name, entity_id): a server player's action removed an entity's leash, after its leash state was cleared. -
native_player_velocity(player, name, velocity): the server synced changed motion to a player, withvelocityin blocks per tick. -
native_player_stop_spectating_entity(player, name, entity_id): a spectator's camera returned to their own player after they stopped watchingentity_id. -
native_player_map_filled(player, name, map_id): a server player filled an empty map, after the filled map and its saved map data were created. -
native_player_teleport_end_gateway(player, name, gateway, cancel): a player is about to travel through the End gateway atgateway. Setcancelto leave them at the entrance; the gateway does not start its cooldown. The player is not swapped in. -
native_player_recipe_book_click(player, name, recipe, all): a server player requests a known recipe be placed in their crafting grid through the recipe book.allis true for the shift click form. The player is not swapped in. -
native_player_exp_cooldown_change(player, name, ticks): the player’s XP orb pickup cooldown changed, after its new value is stored. This includes the two tick delay after an orb is picked up and each tick it counts down. The player is not swapped in. -
native_entity_enter_block(entity, kind, pos, block): a bee was stored in the beehive or nest atpos. -
native_entity_change_block(entity, kind, pos, from, to): a mob changed a block, after the new state was applied. -
native_entity_break_door(entity, kind, pos): a mob finished breaking a door atpos, after the block was removed. -
native_creeper_power(entity, lightning): a lightning strike charged the creeper. -
native_living_visibility(entity, looking, visibility): how visibleentityis tolooking(NeoForge'sLivingVisibilityEvent, last inLivingEntity.getVisibilityPercent).visibilitycomes in as vanilla's percent: sneaking 0.8, invisibility scaled by armour cover, and 0.5 for a worn mob head whose defaultmob_visibilitynames the looking mob's type (a zombie head fools zombies, not husks). Multiply it to scale the range a targeting check sees the entity from. A negative answer counts as 0, and the result is clamped to 10, as vanilla clamps. Horizon computes visibility only for players, so it asks for a player seen by a mob whose targeting check scales by visibility (TargetingConditions.testInvisible): hostile targeting, the sensors, the happy ghast's tempt. It asks after the alive, spectator and selector checks, asTargetingConditions.testdoes. With no listener,MobWorld::mob_hooksisNoneand nothing is asked. -
native_entity_mob_griefing(entity, can_grief): mayentitychange the world (NeoForge'sEntityMobGriefingEvent,EventHooks.canEntityGrief)?can_griefcomes in as themob_griefingrule. Horizon asks with the entity responsible for the change: the exploding entity for a mob explosion, the owner for a mob's arrow or small fireball, the wither for its rose, otherwise the mob itself. Door breaking, the dragon's walls, pitcher crops, weaving, and the explosion's decorations, items and vehicles don't ask: the rule decides. The game asks throughGame::can_entity_griefand the mob AI throughMobWorld::can_grief. With no listener both read the rule: onelistenedcheck, no dispatch. A large fireball's fire asks for its owner: the ghast that shot it, or the player who sent it back. A fireball whose owner is gone reads the rule. The blast's block damage still reads the rule, because Horizon's mob explosion has no source entity yet. -
native_enderman_anger(enderman, player, cancel):playerstares atenderman(NeoForge'sEndermanAngerEvent, last inEnderman.isBeingStaredBy, once the look cone, line of sight and carved pumpkin checks passed). It is asked each time the enderman's goals check the stare: to get angry, to freeze while looked at, and to teleport away up close. Setcanceland that stare doesn't count. The mob AI asks throughMobWorld::mob_hooks, which is set only on the server while a mod listens: with no listener the stare check costs nothing more.Mob AI asks mods through
MobWorld::mob_hooks, anOption<&dyn MobHooks>: one trait in the client lib, implemented once by the game, whose methods only build the hook's arguments and call its generatednative_*entry. It isSomeonly on an authoritative server while a mod listens to one of its hooks, and each method is gated by its own hook'slistened, so adding a method costs nothing to mods that don't listen to it. The hook gets a readHostover the game while the mob tick runs: the mobs being ticked and the level's random and attachments are lent to the tick, so through that host every mob's handle reads as gone and level attachments read as none. Use the event's own fields; anything that must write waits for a deferred hook. -
native_creeper_ignite(entity, player, item): a player used a creeper igniter on the creeper, after it entered its ignited state. -
native_exp_bottle(pos, experience): an experience bottle broke and its orbs were created. -
native_firework_explode(entity, pos, bursts): a firework rocket completed its damage and explosion effects. -
native_potion_splash(item, pos): a thrown splash or lingering potion finished applying its hit effects atpos. Experience bottles are excluded. -
native_entity_enter_love_mode(entity, kind, player): a mob entered love mode after a player interaction;playeris the feeder UUID when known. -
native_teleport_command(dimension, entity, from, to, cancel): a server entity is about to be teleported by/tpor/teleport.dimensionis the level it arrives in,entityits network id,fromits feet now andtowhere the command sends it; movetoto redirect it, or setcancelto keep it where it is (the command still reports success). -
native_entity_teleport(dimension, entity, from, to): a server entity completed a same-level teleport command.entityis its network id;fromandtoare coordinates indimension. This reports the move after it happens. -
native_entity_breed(parent, partner, child): two mobs completed a breeding action.childis absent when the vanilla action lays an egg instead of creating a baby. -
native_entity_tame(entity, kind, owner): a mob became tamed after its owner and persistent state were set.owneris the server player's UUID when known. -
native_item_spawn(entity, item, count, pos): a dropped item entity was successfully added to the server level. The count is its stack size andposis its spawn position. -
native_entity_pose_change(player, name, from, to): a guest player's synchronized pose changed. Pose names areSTANDING,CROUCHING,SWIMMING,FALL_FLYING,SPIN_ATTACKorSLEEPING. -
native_entity_toggle_glide(player, name, gliding): a guest player's fall flying state changed after the server accepted or ended a glide. -
native_entity_shoot_bow(player, name, projectile, force): a player fired an arrow from a bow or crossbow, after the arrow entered the level.forceis the shooting power. This reports the completed shot. -
native_arrow_nock(player, name, bow, hand, has_ammo, cancel): a player is about to start using a bow, whereBowItem.useruns on the server (NeoForge'sArrowNockEvent).has_ammois whethergetProjectilefound ammunition, always true in creative. Setcancelto fail the use: the bow is not drawn. Crossbows and tridents do not ask, and the client's own prediction does not. -
native_living_get_projectile(player, name, entity, kind, weapon, projectile): the ammunitionLivingEntity.getProjectile(weapon)returns (NeoForge'sLivingGetProjectileEvent). Every mod gets the event withprojectileset to the vanilla ammunition (the stack found in the hands or inventory, a mob's held projectile or a new arrow, a creative player's default arrow, or empty) and may change or empty it. It is asked on the bow's start and release, the crossbow's start and draw, the client's prediction of those starts, a skeleton's or illusioner's shot, and a pillager's or piglin's crossbow load (entityandkindname the mob,playeris empty); a mob's arrow takes the replaced arrow's item and effect, a mob's crossbow also fires a replaced firework rocket, and an emptied stack leaves its crossbow uncharged and silent. A replaced stack is not taken from the inventory: the hook owns it. -
native_projectile_launch(player, name, entity, kind): a projectile was added to the server level.kindis its entity type;playerandnameidentify a player launcher, and areNoneand empty for other sources. -
native_entity_portal(dimension, entity, pos): a server entity finished its portal wait and is beginning to leave through the portal block atpos. The destination follows the existing portal rules; the hook reports the transfer without changing it. -
native_furnace_extract(player, name, pos, item, count): a server player takes furnace output atpos, after the output is removed; the player state is not swapped in. -
native_furnace_start_smelt(pos, item): a furnace atposbegins cookingitem, when its cooking timer first advances. Server-side. -
native_brewing_complete(pos): a brewing stand finished brewing atpos, after the bottle contents changed. -
native_brewing_fuel(pos, fuel): a brewing stand atposconsumedfueland gained brewing fuel. Server-side. -
native_enchant_item(player, name, pos, item, levels): a player applied an enchantment atpos;itemis the finished item andlevelsthe levels charged. Guest state is not swapped in. -
native_prepare_item_enchant(player, name, pos, costs): enchanting offers were calculated atpos; change the three offer costs before they are sent to the player's menu. Guest state is not swapped in. -
anvil_update(host, event)(AnvilUpdateEvent, both logical sides): afterAnvilMenu.createResult, changeevent.update's output, XP cost and material cost, or cancel for no output at cost zero.xp_costis signed and preserved exactly, matchingAnvilUpdateEvent.setXpCost(int). A Horizon client is shown that same cost, including one outside the vanilla data-slot short, so it does not change sign on the way. Zero and negative costs prevent taking the output, even in creative (AnvilMenu.mayPickuprequires a positive cost); a negative cost does not refund levels. Negative material costs still clamp to zero, which consumes the entire right stack when a positive-cost output is taken. -
native_anvil_craft_pre(player, name, pos, craft, cancel): a player took an anvil's result atpos(NeoForge'sAnvilCraftEvent.Pre), before the levels are charged, the inputs used up and the anvil worn.crafthas the left and right inputs and the result as they were before. Setcancelto refuse the take and skipnative_anvil_craft_post: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. Fires on the server only; the window's predicted take does not call it. -
native_anvil_craft_post(player, name, pos, craft): the levels, inputs and anvil wear of that take are done (NeoForge'sAnvilCraftEvent.Post); a broken anvil is already air.craftis the same as innative_anvil_craft_pre. Not called when it cancelled. -
native_prepare_smithing(player, name, pos, result): a smithing result was prepared for a player atpos; change it before it is sent to their menu. Guest state is not swapped in. -
native_prepare_item_craft(player, name, pos, result): a crafting-grid result was prepared;posis the crafting table, or None for the player's inventory grid. Change it before it is sent to the menu. Guest state is not swapped in. -
native_trade_select(player, name, villager, index): a player selected offerindexin a villager's trading menu;villageris its entity id when known. The menu has updated and the player's state is not swapped in. -
native_hopper_pickup_item(dimension, pos, item, count, cancel): a hopper atposis about to pick up a dropped stack; setcancelto leave it in the world. -
native_weather_change(dimension, raining, thundering, cancel): the server's/weathercommand is about to set its final rain and thunder states indimension. Setcancelto keep the previous weather parameters, including their timers. -
native_thunder_change(dimension, thundering, cancel): the server's/weathercommand is about to change thunder. Setcancelto keep the previous thunder state and timer. -
native_lightning_strike(player, pos, cause, cancel): a weather, Channeling-trident, or/summonlightning bolt is about to be created. Setcancelto prevent the bolt and its effects;causenames the creation path. -
native_entity_struck_by_lightning(entity, bolt, cancel): a lightning bolt is about to hitentity(NeoForge'sEntityStruckByLightningEvent), asked before vanilla'sEntity.thunderHit, once per strike for each mob, player, item, vehicle or other entity whose box meets the strike box. Setcancelto spare it: no fire, damage or conversion, andnative_creeper_poweris not called. It still counts as struck for Channeling's advancement, as in NeoForge. A visual-only bolt asks nothing. With no listener the cost is onelistenedcheck per struck entity. -
native_time_skip(dimension, from, to, cause, cancel): the server clock is about to jump after enough players sleep or through/time set,/time add, or/time set <marker>. Setcancelto keep the old time. -
native_sleep_finished_time(dimension, current, new_time): enough players slept through the night and theadvance_timerule is on (NeoForge'sSleepFinishedTimeEvent).new_timecomes in as the next morning's day time; set another to wake everyone then. A time beforecurrentis refused and the morning stays; the check runs once after every mod, so a later mod sees the value an earlier one set. It runs beforenative_time_skip, which hears the jump to the time chosen here and may still cancel it; the players wake either way. -
native_world_border_bounds_changed():/worldborder setor/worldborder addchanged the border size or started a timed size change. -
native_world_border_center_changed():/worldborder centermoved the border center. It hears successful commands only. -
native_spawn_change(pos, angle):/setworldspawnmoved the world's spawn point, after its position and rotation are applied. -
native_portal_create(dimension, pos): a Nether portal built for dimension travel or the End arrival platform was completed indimension;posis its lower corner or platform origin. -
native_raid_spawn_wave(raid, pos, wave, raiders): a raid wave is about to be spawned atpos;raidersis the count Horizon will spawn. -
native_raid_state_change(raid, pos, state): a raid atposwas triggered or changed tovictory,loss, orstopped. ItsRaidRefidentifies the owning level, not necessarily the level currently in place. -
native_world_saved(wait): the server completed its world save cycle;waitis true when chunk writes were flushed before returning. -
native_entities_load(dimension, chunk, count): saved entity records forchunkfinished loading intodimension. -
native_entities_unload(dimension, chunk, count): entities inchunkleft the active world and were queued for saving. -
native_tab_complete(player, name, command, range, suggestions): a guest's server completion request was evaluated; mods may add or filter suggestions.rangeis the replaced byte range. Guest state is not swapped in. -
native_broadcast_message(line, cancel): a system broadcast line is about to be logged and sent to every player. A mod may changelineor setcancelto drop the broadcast. -
native_whitelist_state_update(enabled): the dedicated server's player whitelist was enabled or disabled. -
native_player_bundle_item_select(player, name, slot, index): a server player selected an item in a bundle;slotis the packet's menu slot andindexis the chosen item index. -
native_player_ready_arrow(player, name, arrow): a server player released a drawn bow, by the use-item key or a mod'srelease_using_item, witharrownaming the selected projectile item. The player's state is swapped in. -
native_prepare_hopper_transfer(pos): a hopper atposbegins a transfer attempt, after its cooldown and enabled checks and before pushing or pulling items. -
native_inventory_move_item(source, destination, item, cancel): a hopper is about to move one item between containers (a composter's bone meal too). Setcancelto leave it in the source slot. A chest boat or minecart is reported at the block it is in. -
native_hopper_inventory_search(hopper, container, entity): a hopper selects a block container or container entity while searching for items. -
native_vault_loot(pos, item): a vault is about to eject a loot item, before the item entity spawns. -
native_world_border_bounds_change_finish(): a timed world border size change finished on the server, after the border reached its final size. -
native_player_launch_projectile(player, name, item): a server player launched a snowball, egg, ender pearl, potion or trident. It fires after the projectile is created and only for guest players in a LAN world. -
native_entity_unleash(entity, kind): a mob's leash was removed, before any lead drop is handled. -
native_entity_drop_item(entity, item, count, pos): a mob is about to dropcountofitematpos. -
native_item_despawn(entity, item, count, pos): a dropped item entity reached its 6000-tick lifetime and is about to be removed. -
native_explosion_prime(center, radius): a server explosion is about to begin atcenterwithradius. It is listen-only, so the explosion proceeds as usual; it also fires on the single-player server. -
native_vehicle_damage(kind, damage, cancel): a player is about to damage a boat or minecart. Changedamageor setcancelto leave its damage and wobble unchanged; the attack still completes.kindisboator the minecart type. Server-side. -
native_vehicle_destroy(kind, pos): a boat or minecart is about to be destroyed atpos. TNT minecarts that prime instead of breaking are not heard. Server-side. -
native_vehicle_create(kind, pos): a boat or minecart joined the level atpos, after being placed by a player or dispenser. Server-side. -
native_player_toggle_sneak(player, name, sneaking): a guest's player input changed sneaking, after the hub applied the state. The guest's state is swapped in; only actual changes are reported. -
native_player_input(player, name, flags): a loaded guest sent aPlayerInputpacket, after its flags were applied to the hub.flagsis the packet's bit field, and the guest's state is swapped in. -
native_player_arm_swing(player, name, hand): a guest's arm swing was handled and their attack strength reset. The packet currently swingsmainhand; the guest's state is swapped in. -
native_player_jump(player, name): accepted guest movement made the server player jump from the ground, after movement simulation finishes. The guest's state is swapped in. -
native_player_kicked(player, name, reason): a guest was kicked after the hub recorded the rendered disconnect reason and sent the disconnect. The guest's state is swapped in, unless the guest was already removed in the same poll (a kick and the disconnect arriving together): the hook still fires, without it. -
native_player_level_change(player, name, from, to): a server player's experience change altered their level, after the new level was applied. The guest's state is swapped in when the player is a guest. -
native_player_swap_hands(player, name, cancel): a player pressed the swap-hands key (not a spectator), heard before the main and off hand items swap. Setcanceland they stay where they are; the player's client, which swapped them at once, gets its slots back. The player's state is swapped in, sohost.players().acting_player,PlayerRef::telland the item helpers reach them. Not heard: the same swap from an open inventory (hovering a slot and pressing the key) and the host of a LAN world. -
Services:
host.players().server_players()returns online player handles in connection order;player.tell(host, text)sends a system line to one player, also the acting player inside their hook;host.messages().broadcast(text), to every player and the server's log;native_server_event(name, uuid, message), anannounceevent on a dedicated server's event stream, which the Discord bridge relays when[discord] eventslistsannounce;host.commands().feedback(text), the command's answer to the player whose command it is;native_world_dir(), the open world's folder, where a server mod keeps its data in a folder of its own so the world's backups carry it. -
Items: a player's inventory is their
ENTITY_ITEMShandler, from any server hook. -
Experience:
native_experience_orbs(dimension, min, max), the orbs in a box (id, position,value, andcountfor merged orbs), read before a mod cancels an orb innative_entity_join_level; and a player'sgive_experience_points(host, points)andgive_levels(host, levels)through theirPlayerRef: points given as touching an orb gives them, Mending repairing their damaged items first, the rest going to their experience with the level-up sound; levels added or, negative, taken off (Player.giveExperienceLevels, as the anvil and the enchanting table charge; below 0 the level and points are 0; no level-up sound). Both return false for a player not here, dead, or swapped out by another player's hook, and where the server's player list is busy (some world ticks hold it): give first, and cancel the orb only when the give returned true. A merged orb is worthvalue×count. -
native_player_totem(player, name): a totem of undying saved a player whose hurt would have killed them, on a server (checkTotemDeathProtection), heard just before it is used up. Listen only: the health state machine decides before a mod is asked, so a mod cannot veto it. The player's state is swapped in, sohost.players().acting_player,PlayerRef::telland the item helpers reach them. Not heard: the host of a LAN world. -
host.players().acting_player(), the player whose state is swapped in (insidenative_run_mod_command,native_right_click_block,native_use_on,native_break_block...; None elsewhere);host.commands().run_command(line), a command run as the server's console (level 4, no output), including during a guest's turn. The acting player and other live guests are reachable; displaced outer players are excluded only on this native path, without changing ordinary player or console command resolution. A teleport to another level (execute in minecraft:the_nether run tp Alex 0 70 0) lands at the next tick's start. The returned player handles read health through their living handle (0 once dead).player.action_bar(host, text), a line over a player's hotbar, which also reaches the acting player inside their hook. -
native_food_level_changed(player, name, from, to, cancel): the food level of a player is about to change, on a server (eating, hunger and saturation effects, exhaustion, starving), before the new level reaches them. Changeto(0 to 20) or setcancelto leave it atfrom, saturation included. It is heard once for what one turn of the player changed, not for the full bar a respawn gives, the food a player joins with, or the host of a LAN world. The player's state is swapped in, sohost.players().acting_player,PlayerRef::telland the item helpers reach them. -
native_right_click_block(click, use_block, use_item, cancellation_result, canceled): NeoForge'sPlayerInteractEvent.RightClickBlock, a player's right click on a block, once per hand whereuseItemOnstarts, on the clicking client and on the server (click.side).click(BlockClick) names the level, block, hit, hand, held stack and the player's UUID and entity id.use_blockanduse_item(TriState) force the block's use or the item'suseOnoff, or on (the block even while sneaking with something in hand, the item even while it cools down). Cancelling skips both and answerscancellation_result(InteractionResult, defaultPass, which goes on to the item's own use and the other hand); withnative_break_block, a cancel withFailkeeps players out of claimed land, and the player's client is sent the block back. -
native_use_item_on_block(click, phase, cancellation_result, canceled): NeoForge'sUseItemOnBlockEvent, on both sides, immediately before the selected block or item-on-block phase. Canceling replaces that phase withcancellation_result(defaultTryWithEmptyHand), skipping its Part and template.UsePhase::ItemBeforeBlock,BlockandItemAfterBlockall fire;ItemBeforeBlockprecedes the item'son_item_use_firstPart. -
native_right_click_item(click, cancellation_result, canceled): NeoForge'sPlayerInteractEvent.RightClickItem, on both sides, beforeItem.useand its Part after target fallthrough. Canceling answers withcancellation_result(defaultPass). On the client, every canceled result exceptSuccesscontinues to the other hand without a predicted swing. -
native_block_spread(dimension, kind, from, to, canceled)andnative_piston_pre(dimension, piston, direction, extending, push, destroy, canceled)andnative_piston_post(dimension, piston, direction, extending, push, destroy)(onGameLevel, like the block observers): a fluid flowing or fire spreading (kindfluidorfire) fromfromintoto, and a piston moving blocks once its structure resolved (native_piston_pre, NeoForge'sPistonEvent.Pre;native_piston_post,PistonEvent.Post, hears the move done). Canceled, the block attostays as it is, and the piston moves nothing. Fluids ask on every flow: answer from memory, fast. -
native_block_ignite(dimension, player, pos, cause, cancel)(onGameLevel): fire is about to be placed. It covers fire spread, lava's random fire placement, lightning strikes, and dispenser flint-and-steel. Setcancelto leave the position unchanged. Direct player flint-and-steel/fire-charge placement is not covered. -
native_block_burn(dimension, pos, cancel)(onGameLevel): fire's burnout check is about to remove the block atpos. Setcancelto keep it. -
native_crop_grow_pre(dimension, pos, state, result)andnative_crop_grow_post(dimension, pos, original_state, state)(onGameLevel): NeoForge'sCropGrowEvent.Preand.Post, at the random-tick growth of crops (beetroot and torchflowers after their own 1 in 3 skip), stems, nether wart, cocoa, sweet berries, bamboo, cactus, chorus flowers, sugar cane, kelp and the vines.resultis aTriState:Defaultkeeps vanilla's chance,Truegrows,Falsedoesn't. Pre fires at the position that grows (the block above for cactus and chorus, the block grown into for kelp and vines). Vanilla's random roll is drawn first, in vanilla's order, and passed as the default, so crops keep vanilla's random sequence. Pitcher crops, bamboo saplings and mangrove propagules don't fire. -
native_dragon_egg_form(dimension, pos, cancel)(onGameLevel): the first Ender Dragon fight is about to place its egg atpos. Setcancelto leave the block unchanged. -
native_fluid_level_change(dimension, pos, fluid, from, to, cancel)(onGameLevel): flowingwaterorlavaatposis about to change level. Levels run from 0 through 8. Setcancelto keep the old level. -
native_neighbor_notify(dimension, pos, state, skipped_side, cancel)(onGameLevel): NeoForge'sNeighborNotifyEvent. The block atposis about to tell its neighbours it changed:ServerLevel.updateNeighborsAt(all six sides,skipped_sideisNone) orupdateNeighborsAtExceptFromFacing(all butskipped_side). Setcanceland no neighbour hears, on both paths. Asked for every such update, redstone included: answer fast. -
native_create_fluid_source(dimension, pos, state, can_convert)(onGameLevel): NeoForge'sCreateFluidSourceEvent. When the game works out a block's new fluid (FlowingFluid.getNewLiquid), it asks once for each horizontal neighbour that is a source of the same fluid, with that neighbour'sposandstate; two counted sources over a solid block or a source make a new one.can_convertstarts as the fluid's rule (water_source_conversionorlava_source_conversion). Spread checks work out new fluids too, so this is asked often: answer fast. -
native_leaves_decay(dimension, pos, cancel)(onGameLevel): a non-persistent leaf block at distance 7 is about to decay. Setcancelto keep it. -
native_inventory_block_start(player, name, pos, cancel): a guest is about to open an inventory supplied by a block atpos. Setcancelto leave it closed. The guest's state is not swapped in. -
native_moisture_change(dimension, pos, from, to, cancel)(onGameLevel): farmland atposis about to change moisture. Setcancelto keep its old moisture. -
native_sculk_bloom(dimension, pos): after a sculk catalyst atpostakes experience, its block has entered the blooming state. -
native_sponge_absorb(dimension, pos)(onGameLevel): a sponge atposhas absorbed water and become wet. -
native_target_hit(dimension, pos, strength): a projectile hit the target block atpos, which now has the reported redstone outputstrength. -
native_vault_change_state(dimension, pos, state): the vault block atposchanged its state tostate(inactive,active,unlocking, orejecting). -
native_block_failed_dispense(dimension, pos, item): a dispenser atposfailed to dispenseitem. -
native_beacon_state_change(dimension, pos, active): the beacon atposgained or lost its complete beam pyramid;activesays whether it is active now. -
native_anvil_damaged(dimension, pos, from, to): the anvil atpostook damage, changing fromfromtoto;tois air when it broke. -
native_block_break_progress_update(player, name, pos, progress): a guest's crack animation atposreached progress stage 0 through 9. -
native_block_lock_check(player, name, pos, locked): a guest checks the lock on the container atpos;lockedstarts with vanilla's result and may be changed. The guest's state is not swapped in. -
native_block_fade(dimension, pos, from, to, cancel)(onGameLevel): ice or snow melts, or coral dies.fromandtoare block state IDs. Setcancelto keep the old block. -
native_block_form(dimension, pos, from, to, cancel)(onGameLevel): precipitation is about to place ice or snow, or add a snow layer atpos.fromandtoare block state IDs. Setcancelto keep the old state. -
native_bell_ring(dimension, player, pos, cancel)(onGameLevel): a bell is about to ring. Setcancelto stop the ring and its raider reveal. -
native_cauldron_level_changed(dimension, player, pos, from_level, to_level, reason, cancel)(onGameLevel): precipitation, dripstone, or a burning player being extinguished is about to change a cauldron's level. Setcancelto leave it as it was. -
native_random_tick(ticks, dimension, pos, state, handled)(onGameLevel): a blocktickChunkrandom-ticks, before its ownrandomTick. Change the block with the level'snative_set_block_property(ticks, pos, key, value, flags); sethandledto skip the block's own random tick. -
native_tick_block(ticks, dimension, pos, state, handled)(onGameLevel): a due scheduled tick of a block that is still there (ServerLevel.tickBlock), before its owntick; the same contract asnative_random_tick. -
native_explosion_detonate(dimension, center, blocks): the positions an explosion is about to break, before any is hit; take out the ones to spare. Entity damage is unchanged. -
native_explosion_knockback(dimension, center, blocks, entity, knockback_velocity): a server explosion's calculated velocity, before it is added to the entity. Replaceknockback_velocityto change the push;entityis None for a player, whomhost.players().acting_playernames. The player packet uses the same replacement. Wind bursts are heard too. -
native_fill_bucket(stack, pos, canceled)andnative_attack_entity(entity, kind, pos, canceled): a player's bucket at a block, and their blow on an entity, with the acting player swapped in. Canceled, the bucket does nothing and the blow does no harm. A fluid or mob bucket also asks about the block beside the clicked face whenever that block is not its target, since a block that refuses the fluid passes it there; the fluid may still stay in the target.
Mobs
crates/horizon-client/src/game/modding/native_mobs.rs: helpers for server mods, for the level in place, on a server only (on a
client they do nothing, and the readers give nothing). Each takes the dimension (minecraft:overworld) of the level it means, as
native_set_block does, and is refused when another level is in place. The per-level tick hooks run with a defined level in
place; native_server_tick does not define one, so name the dimension and expect a refusal there.
LevelRef::spawn_mob(host, kind, pos, reason)spawns a mob and returns anEntityRef, orNonewhen refused.SpawnReasoncontrols finalization; the new mob is not persistent, as for a natural spawn. Spawn limits and natural spawn rules do not apply, as for/summon. A non-mob kind, invalid or out-of-bounds position, unloaded chunk, stale or unavailable level, or cancellation during finalization or level entry refuses the spawn. Configure the resulting entity through its handle rather than a spawn-options record.native_mob(dimension, id)andnative_mobs(dimension)read the living mobs: id, dimension, kind, position, yaw, health and maximum health.- A mob's health is set with
living.set_health(host, health)on itsLivingRef. native_kill_mob(dimension, id)kills as/killdoes, with its loot.EntityRef::discardremoves an entity without death or loot: from the call on selectors and handles no longer find it, its passengers are off andentity_leave_levelhas fired, in the same hook.- While the game ticks its mobs they are out of the level: from a hook inside that tick, a spawned mob joins after the tick, and the others read as gone.
Loot tables
Changing loot a mod didn't make, as NeoForge's LootTableLoadEvent and global loot modifiers and Fabric's
LootTableEvents.MODIFY and MODIFY_DROPS do. A mod's own loot function and condition kinds are #5451.
horizon_world::loot::native_loot_table_load(context, id, table): each table's JSON as it loads, after the data packs'.Hooks::loot_table_loadgets the owning load's&dyn DataLoadContextfirst (see "Items' default components"); itsorigin()is alwaysServerDataLoad, since only the server loads loot tables. Push a pool ontotable["pools"]or an entry onto a pool'sentries; chests, block and mob drops, fishing and the rest then roll the changed table. Keep a replaced table'srandom_sequence: a mob's table without one drops nothing. It runs once per table on each server data load and/reload, not on client preloads. It is a free function incrates/horizon-world/src/loot.rs, so a helper injected there compiles insidehorizon-world, withoutcrate::game.native_loot_drops(context, drops): the items a broken block or a dead mob is about to drop, after its tables rolled (a mob's worn, held and carried items and inventory too, not an enderman's carried block or a copper golem's preserved equipment). Add, change or take out stacks; empty ones don't drop.LootDropssays what dropped them: its own loot table (empty when none rolled), the dimension, the origin, the block's position and state or the mob's id and type, the player who broke or killed it (100-tick player kill credit), the tool and explosion radius. Deaths also carrydamage_source(type, causing/direct IDs), andtoolis the killer's full held snapshot, not a later inventory read. It hears blocks that a player, an explosion or a mob breaks, not other breaks (leaves decaying, a torch losing its wall, a piston,/setblock ... destroy), nor a creative break or a tool that can't harvest the block. A block's stacks get its block entity's data (a shulker box's items) after the hook, and a container's contents spill on their own, not through it. A player's use drops through it too, withusedset and that player named: shears on a sheep, a mooshroom, a snow golem or a bogged (its shearing table), a brush on an armadillo (minecraft:brush/armadillo) or a suspicious block (its item, no table), shears carving a pumpkin, picking sweet berries or cave vines (their harvest table), and a player's hit taking an item frame or its item (the frame's entity, no table). A dispenser's shears and an explosion's frames aren't told. Leads: untying one or cutting it with shears is a use (the leashed mob, no table), and a mob a player killed while leashed drops its lead on its next tick as a call of its own, naming the killer (usedfalse, no table). A lead that snaps or loses its holder names no player and isn't told.native_living_drops(entity, kind, player, source, recently_hit, drops, cancel): NeoForge'sLivingDropsEvent, the whole of what a dying mob, server player or armor stand drops, afternative_loot_dropsand with the rest ofdropAllDeathLootin: an enderman's carried block, a copper golem's preserved equipment, and a player's inventory (unlesskeepInventory). EachItemDropholds the stack and the position, motion and pickup delay its item entity gets; item entity state such as no-gravity, owner and lifetime isn't exposed through it.sourceis aHurtSource(the damage type,causinganddirectentity ids); the id of a projectile isn't kept, sodirectis 0 for it. Edit the list or setcancelfor no drops; experience orbs are separate (native_living_experience_drop). An armor stand fires it frombrokenByAnythingwith an empty list, and its stand item and equipment pop outside the list, as in vanilla.native_loot_dropsstays for loot that is not a death (blocks, shearing, leads); for a mob's death it is heard first (it edits the stacks a table rolled), and this hook then fires once on the whole collected list, with nothing spawned until it returns.native_get_equipment_drop_chance(entity, kind, slot, source, recently_hit, stack, original_chance, preserved, chance): NeoForge'sGetEquipmentDropChanceEvent. Change the chance a dying mob rolls for the itemstackinslot;original_chanceis the recorded chance before enchantments andpreservedsays the slot drops without a player kill. Each slot is asked at its own point ofMob.dropCustomDeathLoot, just before its roll, and the roll is drawn whatever the chance is.native_enchanted_entity_loot(entity, source, attacker, enchantment, level): NeoForge'sEnchantedEntityLootEvent. Change the level an entity's loot table uses ofenchantment, in the middle of the roll:EnchantedCountIncreaseFunctionandLootItemRandomChanceWithEnchantedBonusConditionask each time they are evaluated, on a death and on/loot ... kill, whosesourceisminecraft:magicand whoseattackeris the command's source.entityandattackerare live handles;attackeris None without one and may not be living, andlevelthen starts at 0, otherwise at the living attacker's level. It is unsigned, so the negative levelssetEnchantmentLevelthrows on can't be written, and above 1024 it counts as 1024, so a mod's value can't size a loop. Without a listener the roll asks nothing, and its random's order is vanilla's.
A mod block entity's data on its item
A mod's block entity carries its saved data on the item as vanilla's do: Ctrl + Pick Block, a loot function that copies the block entity (minecraft:copy_components-style) or a /give with minecraft:block_entity_data:{id:"mod:type",...} puts the compound (without x, y, z) in the item's minecraft:block_entity_data, and placing the item merges it into the new block entity before native_place_block runs, the counterpart of NeoForge's BlockItem.updateCustomBlockEntityTag and BlockEntity.saveToItem. Data tagged with another type is ignored. /give and item replace accept a type id a mod declared (its content's block entity types) and refuse any other as vanilla refuses an unknown one. The data is the compound the block entity saves, without its position and its components (saveCustomOnly): a component patch, a custom name say, rides as the item's own components and comes back as the block entity's on placing. A mod reads the data back in its own load as it does for a chunk.
A block's items
A server mod reaches a block's items through its ITEMS capability (parts): level.capability(host, &ITEMS, pos, side) gives the block entity's slots as that side reaches them, by the rules hoppers follow (a furnace's sides, a double chest's two halves, a mod's ModContainer with its faces, inputs and outputs), and move_resources(host, &from, &to, filter, max, tx) moves items between two of them in a transaction. side is the side of the block being reached, so a pipe above a chest uses Some(Direction::Up). A level not in place, a stale LevelRef and a client's copy of the world give none. native_dimension() names the level in place (minecraft:overworld) for a hook that isn't told it, such as native_block_entity_tick.
During a machine's own tick its block entity is out of the world, so it has no capability then: it has its slots in hand, in the items it's given. A composter has no block entity, so it has no ITEMS: hoppers fill and empty it. Container entities (minecarts, chest boats) aren't reached this way yet.
Entities
crates/horizon-client/src/game/modding/native_entities.rs: helpers for server mods, for the level in place, on a server only. Each
names the level it means by its dimension (minecraft:overworld), as native_set_block does.
native_summon(dimension, kind, pos, nbt)spawns an entity ofkindfrom its compound, as/summon <kind> <pos> <nbt>does (passengers included), and returns its id. None on a client, for another dimension, a position outside/summon's bounds, a chunk that isn't loaded, a kind Horizon can't load, or one a mod kept out withnative_entity_join_level.
A model that moves (a turning gun barrel, a thrown projectile) is an item_display showing an item whose model comes from
the mod's .hmod assets: summon it once, then move it and pose it with DisplayRef from a tick hook. Easing between ticks happens on each
client, so a call a tick looks smooth.
Entity and level handles
crates/horizon-client/src/game/modding/native_entity_refs.rs (#7387): an EntityRef names one entity of the open world and a
LevelRef one level. They are small Copy values a mod keeps, and their methods take the host, so a mod can hold
several at once: host.entity(id) makes one from the id an event carries, host.level("minecraft:the_nether") one from
a dimension.
An entity held out of storage while it is changed refuses handle reads and writes: reads return None and writes return
false. This includes cart and ridden-cart ticks, boat ticks (including ridden-boat leash ticks), and a copper golem turning into a
statue. A block entity also refuses its handle while it is being ticked. Tick hooks before and after that work can use
the handle normally.
The client also owns a world epoch, separate from the server's. It begins when a play login creates the client level, changes when a dimension change or later login replaces that level, and retires after the client logout hook or when reconfiguration clears the level. A same-level respawn keeps the epoch. Client epochs do not fire server world lifecycle or server tick hooks. This bookkeeping does not make the server entity and level reads available on a client; the reads listed under "On a client" below are.
On a client (host.local_player(), a client hook's host), these reads answer from the client's own copy, which is
what the server sent it (vanilla's LocalPlayer, ClientLevel and Entity fields), and are None for a stale
handle, an entity the client doesn't track and a level it doesn't show:
-
host.entity(id)andhost.level(dimension): the local player or an entity the server told the client of, and the one level the client shows.host.living(entity)is the local player only. -
entity.position(host),rotation(host)andvelocity(host): the client's copy, interpolated as it draws it. -
entity.name(host): the synced custom name or type name, and a player's profile name, from either side's host. -
level.block_state(host, pos),block,block_propertyandis_loaded: blocks of the chunks the client holds. A chunk it doesn't hold reads as unloaded; no read loads one. -
level.day_time(host): the synchronized Overworld clock in ticks (counting past 24000), advanced at the server-supplied rate while the client level runs. This remains the Overworld clock in the End, even though the sky uses the End's own default clock. Only a handle for the level the client shows can read it. -
living.equipment(host, slot),hand_item,effect,effects,potion_affected(hasEffect),healthandmax_health, for the local player. Another entity's equipment, effects and health stay server reads, and so does every write. -
player.last_death_location(host), for the local player: the position the login and respawn packets carried. -
player.inventory(host)reads non-empty main inventoryItemStacks in slot order, with components. It returnsNonefor a stale or unavailable player; on a client only the local player's inventory is available. Armor and offhand are separate equipment. -
For the local player, its own state as the server sent it (
LocalPlayer):player.game_mode(host),is_creative,is_spectatorandmay_build(the game mode of the login, respawn andClientboundGameEventpackets);food(level and saturation fromClientboundSetHealth; the client keeps no exhaustion, so it reads 0),needs_food,has_enough_foodandcan_eat;abilities(what the game mode gives, thenClientboundPlayerAbilities' flags and speeds);selected_slot(the hotbar slot held);experience(ClientboundSetExperience) andxp_needed_for_next_level. Another player readsNonethere: the client is not sent their state. -
The local player's open menu:
host.local_player()thenplayer.container_menu(host)makes aMenuReffor the menu the client has open (the player's own inventory when none is), andmenu.slot_count(host),slot(host, index)andcarried(host)read it as the server'sClientboundContainerSetSlotandSetCursorItempackets left it. Indexes are the menu's own slot order (the sameslot_backingnames), not network slot ids. Another player's menu isNone, and so arecontainer_idandkindon a client (server reads). A slot readsSome(None)when empty and the outerNonewhen it doesn't exist or the menu is unavailable; a menu type the client has no slot map for readsNonethroughout rather than guessed contents. A handle goes stale, and readsNone, when its menu is closed or replaced (a reused window id included), on a respawn, a dimension change or a logout, and after a reconnect. Nothing here sends a packet or changes the menu. -
entity.position(host),block_position,rotation(yaw and pitch),kind(minecraft:zombie),uuid(&mut: an entity with no UUID yet gets its own) andlevelread the entity. -
level.block_state(host, pos)returns a typedBlockStatewithout allocating a state string. For text such asminecraft:oak_stairs[facing=east,…], pass the ID tohost.block_state_string(id).block(the name alone),block_property(host, pos, key),is_loaded,height(lowestyand height),game_timeandday_timeread any level the server has loaded, not only the one in place. A block in a chunk that isn't loaded or ayoutside the world's height reads asNone.level.brightness(host, LightLayer::Block | Sky, pos)reads the stored light (getBrightness) the same way, as the game's own light query holds it, and isNonewhere the block read is.level.height(host)(LevelHeightAccessor.getMinY,getHeight) also works with&dyn Hostin client hooks, including render, screen and mouse hooks. Obtain the handle withhost.level(dimension); on a client only its current dimension is available, after login. It reads that client's synchronized level shape, without loading chunks or allocating. A stale handle, logout or a different dimension returnsNone, not guessed Overworld bounds. On a server it still reads parked loaded levels as well.level.fluid_state(host, pos)reads the fluid there the same way.level.fluid_flow(host, pos)(FluidState.getFlow) reads its current as aVec3, through the function that pushes entities, so afluid_flowlistener's change is what it reads: zero where no fluid flows,Nonewhere the block read is. It reads on a client too, from the client's own copy of the level. It costs what one entity's push by that block does: up to twelve block reads aroundpos(the four neighbours and below them; falling fluid also checks their faces), with no allocation, plus thefluid_flowlisteners. -
level.weather(host)reads rain and thunder levels on both sides, using the sharedHostfrom server, render, screen or mouse hooks. It returnsSome((rain, thunder)), each 0.0 to 1.0, at partial tick 1.0 (Level.getRainLevel/getThunderLevel, with thunder scaled by rain). Both are 0 where the level cannot have weather. A server can read any open level, including parked levels; a client reads only its current synchronized level after login. Stale handles and unavailable levels returnNone; the read never loads a level. -
level.seed(host),difficulty("normal", as/difficultynames it),game_rule(host, "keep_inventory")(aGameRuleValue,BoolorInt; None for an unknown rule),game_rules(every rule as aminecraft:id and its value),is_rainingandis_thunderingread its server settings.level.is_raining_at(host, pos)(Level.isRainingAt) asks about one position of any open level: it rains there, the position has full sky light with nothing motion-blocking or fluid above it, and its biome's precipitation at that height is rain (not snow or none). It isNonewhere the block read is, an unloaded chunk or ayoutside the world.level.enabled_features(host)is the world'sFeatureFlags(LevelReader.enabledFeatures:contains("minecraft:redstone_experiments"),iter()); every level of a world shares them, and a world that names none enablesminecraft:vanilla. -
level.chunk_ref(host, [x, z])makes aChunkReffor any chunk of an open level, loaded or not (None past 30 million blocks).chunk.forced(host)andlevel.forced_chunks(host)read the chunks/forceloadholds (/forceload query);chunk.set_forced(host, bool)is/forceload addorremoveof one chunk and returns whether the set changed. That is the world's own state: it is saved with the level and stays until/forceload remove. A newly added forced chunk is loaded before the call returns, including in a parked server level. Repeated adds and removals do not load chunks. -
Ticket controllers (NeoForge's
TicketController, registered inRegisterTicketControllersEvent): a mod registers each one once at startup, fromHooks::register, withr.register_ticket_controller("quarry", validates)and keeps theTicketControllerit returns (it isCopy;Hooksmethods take&self, so aOnceLockholds it). The id is the calling mod's:quarryandmymod:quarryboth becomemymod:quarry, and another mod's namespace refuses the mods at startup, as does an id registered twice (the error names the mod and the id).validatesis whether the controller validates its saved tickets (NeoForge'sLoadingValidationCallback, below). The handle is only made by the registrar and indexes the installed table, so a ticket call looks up no string. Another mod's controller does nothing. -
chunk.set_mod_forced(host, controller, owner, forced, natural_spawning)(NeoForge'sTicketController.forceChunk) holds or releases a chunk throughcontrollerand returns whether its tickets changed.owneris aTicketOwner,Block([x, y, z])orEntity(uuid). A ticket is scoped by controller, owner, chunk andnatural_spawning, so two owners in one chunk release separately, one owner holds a plain and a natural-spawning ticket on a chunk apart, and two controllers of one mod never touch each other's. The chunk stays loaded, entity ticking and saved, and its level keeps ticking, while any ticket or/forceloadholds it. Withnatural_spawning, the chunk joins the spawning chunks (ChunkMap.collectSpawningChunks, within 8 chunks of a player): it gets inhabited time, thunder and a spawn attempt. The local mob cap then lets mobs spawn there only while a non-spectator player is within 128 blocks of the chunk's centre, as vanilla'sLocalMobCapCalculatordecides.level.mod_forced_chunks(host, controller)lists the controller's tickets as(owner, [x, z], natural_spawning). Tickets are saved indata/horizon/mod_chunk_tickets.dat, never in vanilla'schunk_tickets.dat, under the controller's id. When a world loads, the tickets of an id no loaded mod registers are dropped with a warning in the log, as NeoForge documents for a controller nobody registers; so are tickets from a file written before controllers, which are keyed by mod id. Outside a mod's hook, for another mod's controller, and for a stale handle or a chunk past the world's bounds, the calls returnfalseorNone. -
validate_tickets(NeoForge'sLoadingValidationCallback.validateTickets, server) fires for each level that opens, afterlevel_loadand before its first tick, once for each controller registered withvalidatesthat has saved tickets in the level, and only to mods that implement it.event.controlleris the controller; its owning mod'sevent.validationshows that controller's tickets read from the save when the level loaded, before they take hold (NeoForge'sTicketHelperis per controller): a ticket a mod adds inlevel_loadis not among them and is never removed by it, and a ticket of another controller is not shown. Every mod that implements the hook is called, but only the owner's view holds anything; a controller without a validator keeps all of its tickets. The tickets that survive then force their chunks.block_tickets()gives each block position andentity_tickets()each entity UUID with aTicketSet, the chunks it holds apart by kind (non_natural_spawningandnatural_spawning, as NeoForge'sTicketSet). A plain and a natural-spawning ticket on one chunk are two tickets.remove_block_tickets(pos)andremove_entity_tickets(uuid)remove everything an owner holds, both kinds (and return how many);remove_block_ticket(pos, chunk, natural_spawning)andremove_entity_ticket(uuid, chunk, natural_spawning)remove the one kind they name and leave the other. A removed ticket never forces its chunk and is left out of the next save. -
host.living(entity)gives aLivingReffor a mob, a player, an armor stand or the dragon (vanilla'sLivingEntity);living.entity()goes back.living.equipment(host, EquipmentSlot::Head)andhand_item(host, Hand::Main)read its equipment (an empty slot reads asNone);set_equipment(host, slot, stack)puts a stack in a slot asLivingEntity.setItemSlotdoes (None or air empties it), andset_hand_item(host, hand, stack)puts one in a hand asLivingEntity.setItemInHanddoes (the main hand's or the off hand's slot, written asset_equipmentwrites it). Mobs store all eight slots (Mob::equipment_stacksreads them as ahorizon_items::equipment::Equipment); players (the host and guests) hold anEquipmentfor body and saddle beside their own hand and armor fields, and save through itsread/write. A player'sbodyandsaddleare kept and saved in theequipmentcompound as vanilla does, and dropped on death, but nothing renders or sends them. Armor stands and the dragon hold all eight slots in the sameEquipment, saved inequipment(omitted when empty). A stand keeps a body or saddle a mod writes (setItemSlotstores it;ArmorStand.canUseSlotonly stops a player equipping it), and pops it with the rest, after the other six slots, when it breaks. A write to a stand or the dragon runsLivingEntity.onEquipItemlike a mob's: the equip sound and theequiporunequipgame event, unless the entity has not ticked yet or the item did not change.native_living_equipment_changehears all eight slots of a stand./item replacekeeps vanilla's predicate for a stand's or the dragon's chest, legs, feet, body and saddle (an empty stack, or an item made for that slot; a stand'scanUseSlotrefuses body and saddle), whileset_equipmentstays permissive. This is not full dragon equipment parity: the dragon keeps its equipment for mods and saves, and does not drop it on death (the death drops are built onMob, which the dragon is not). -
living.swing(host, hand, send_to_self)(LivingEntity.swing) starts an arm swing, shortened by haste and lengthened by mining fatigue, unless one is under way and past its first half (it returns whether one started). The players around see it at the end of the tick, the swinger too withsend_to_self; clients see a mob swing the hand asked for. Mobs and players work; armor stands and the dragon return false, and so does a player other than the acting one while another player acts. -
living.arrow_count(host)/set_arrow_countandstinger_count/set_stinger_count(DATA_ARROW_COUNT_ID,DATA_STINGER_COUNT_ID) read and set what is stuck in a player or a mob; the entity data carries it to the players around, so they see it. Armor stands and the dragon keep no such count (None, false).set_arrow_removal_ticksandset_stinger_removal_ticksset the ticks until the next one falls out (removeArrowTime,removeStingerTime; at 0 or less the timer restarts at20 * (30 - count)); a mob's timers run in its tick as a player's do. -
living.item_use_remaining(host)/set_item_use_remaining(host, ticks)(useItemRemaining) read and move the end of the item a player uses: at 1 the use completes on the next tick, at 0 or less it lasts until the player lets go, as in vanilla. It moves the deadline the use-item Tick hook moves, so shield, bow, trident, spear, instrument and bundle uses, which don't count it, ignore it, and a call inside that Tick hook is overwritten by the hook's result. Nothing in use reads 0 and refuses a write. A mob's use never runs out: its read is the item's use duration less the ticks its use counter has run, and a write moves that counter to whereticksare left, which the stabs of a spear and the draw of a bow follow. A piglin admiring gold and a drinking witch use no item, so they read 0 and refuse a write. -
living.set_riptiding(host, ticks, damage)(startAutoSpinAttack) starts a player's Riptide spin: forticksticks the player turns, the first mob its box touches takesdamageand ends the spin, and the players around see it (living flag 4). It adds no velocity (a Riptide launch does that itself), and aticksof 0 or less ends the spin. Mobs, armor stands and the dragon return false: Horizon runs no spin for them. -
living.set_waypoint_style(host, &style)andset_waypoint_color(host, rgb)(Waypoint.Icon.style,.color) change the locator bar icon of a living entity as/waypoint modifydoes, untracking and tracking its waypoint again, so the players it transmits to get the new icon. A colour ofNoneshows a player in their team's colour. Only a player's waypoint reaches other players (waypoint_transmit_rangeis 0 for every other entity); a mob's or an armor stand's icon is kept for its waypoint and/waypoint list. The dragon returns false. -
living.used_item_hand(host)(getUsedItemHandwhileisUsingItem) reads the hand of the item a player or a mob uses, None when it uses none; on a client only the local player answers.living.start_using_item(host, hand)isLivingEntity.startUsingItem: the item's use timer and the use-item Start hook, not the item's ownuse(no ammo check, no sound). A player's client lets go of a use whose button isn't down, as in vanilla, so that use is released on its next tick.living.release_using_item(host)isreleaseUsingItem, what letting go of the button runs: the use-item Stop hook, the item's own release (a player's bow or trident shoots), a mod'srelease_using, once. A mob's use is its goal's counter: it shows and its goals see it, but it never runs out, so only a release or a goal ends it, and vanilla's bow and trident have no release for a mob. A piglin with something in its off hand and a drinking witch refuse a start and have no use to read or release: the counter is their own timer. -
living.effects(host)lists a living entity's active effects (vanilla'sactiveEffects) asActiveEffects — the load-ownedEffectKey(read its canonical name withkey.id(host)), the ticks left (-1 infinite), the amplifier, and theambient,visibleandshow_iconflags (show_iconis vanilla'sshowIcon, apart from the particles).living.effect(host, key)reads one (getEffect) andliving.potion_affected(host, key)answershasEffect; request the key once throughRegistrar::effect_key.living.add_effect(host, &effect)isLivingEntity.addEffectby the game's own path: a player's or a guest's goes through the potion machinery (its attribute modifiers, absorption, and the added-effect hooks for a content mod's effect), a mob's pastcanBeAffected(a spider shrugs off poison) and with the same hooks. It returns true when accepted — whether it replaced one already there or only extended it isMobEffectInstance.update's business (a stronger one replaces with its own duration, an equal one extends, a weaker one changes nothing).living.remove_effect(host, key)andliving.remove_all_effects(host)take effects away the way their running out does: the attribute modifiers they gave recompute and absorption's hearts go with theirs. The added-effect hooks hear a player's add without an entity id, so a guest's reaches them as the acting player (None) — a limitation of the hooks themselves, unchanged here. Armor stands and the dragon keep no effects, as/effectskips them:effectsandeffectreadNonefor them and their writes return false. On a client,living.effect_blend_factor(host, key, partial)(getEffectBlendFactor) reads how far the local player's effect has faded in at the partial tick: darkness fades over 22 ticks and nausea in over 150 and out over 20. Other effects start at 0 after a blended addition and interpolate to their post-countdown target each client tick (1 while active, 0 once expired). An unblended addition starts at its target immediately; a replacement packet preserves the old blend. Partial ticks are not clamped. A missing effect reads 0. The client keeps no other entity's effects, so they readNone. -
host.fishing_hook(entity)gives aFishingHookReffor a cast hook (FishingHook);hook.entity()goes back.player_owner(host)is the player who cast it,hooked_in(host)what it holds (None when nothing, or when that is gone) andopen_water(host)isisOpenWaterFishing.item_fishednames a hook by id, andLevelRef::entities_infinds them. Discarding one takes it out. It is not aProjectileRef, and selectors do not find it yet. -
host.projectile(entity)gives aProjectileReffor a projectile in the level's projectile list (arrows, a mob's trident, thrown items, fireballs, wind charges, skulls, shulker bullets, mods' thrown types), a trident a player threw or a firework rocket;projectile.entity()goes back. A player's trident and a rocket have an owner and a deflection only:pickup,base_damageandcriticalread None and their setters return false, and a player's trident always has a player owner (set_ownerordeflectwith none or a mob returns false).owner(host)resolves the owner to a currentEntityRef(None when gone or in another level) andset_owner(host, owner)takes any entity but a fishing hook (Projectile.setOwner). An owner that is neither a player nor a mob is kept by its UUID:ownerresolves it and a save keeps it, but a hit credits no one for it.deflect(host, deflection, by, new_owner, by_attack, power)isProjectile.deflect:Deflection::Reversedraws one float for the turn from the projectile's own random (Entity.random, never the level's),AimDeflectandMomentumDeflectuse the look or the known speed (last tick's move,Entity.getKnownSpeed) ofbyand do nothing without it; the new owner is set and a fireball's or wind charge's acceleration power becomes 0.1 by an attack, else halves. A wind charge refuses for its first five ticks (WindCharge.noDeflectTicks): false, with no change and no draw.set_owneron an arrow also allows pickup for a player owner (AbstractArrow.setOwner);deflectdoes not (vanilla'sEntityReferenceoverload). Arrows (and a mob's trident forpickup) also havepickup/set_pickup(ArrowPickup),base_damage/set_base_damageandcritical/set_criticalandpierce_level/set_pierce_level. The pierce level is a signed byte: vanilla stores all values from -128 through 127 unchanged, and nonpositive values do not pierce. The typed result distinguishes a stale handle, a non-arrow projectile and a wrong-side host. The critical flag reaches clients through entity data; these handle methods operate on the server. -
living.drop(host, stack, throw_randomly, include_thrower_name)throws a stack the mod holds from the eyes (LivingEntity.drop) and gives the item entity; it never touches a slot.is_tamed(host)andowner(host)read a tamable mob or a horse (TamableAnimal.isTame, its owner's UUID). -
entity.velocity(host)(a player's is the move its client last reported),set_velocity(host, v)(not for players, whose client moves them),on_ground,bounding_box(lowest and highest corner),eye_height,eye_position,view_vectoranddistance_to_sqr(host, pos)read and move it. -
entity.up_vector(host)isEntity.getUpVector, the view vector turned toward the zenith.on_block(host)isEntity.getOnPos: the supporting block of an entity on the ground (the solid block under its box nearest it), else the block under its feet.pick(host, distance)clipsEntity.pick's ray from the eyes along the view against block outlines, no fluids, and returns theClipHit(itsmissset for a miss); entities areray_trace_entities' job.is_multipart(host)andparts(host)areEntity.isMultipartEntityandgetParts: the ender dragon's eightEnderDragonParts (not the Parts of this API), head, neck, body, three tail segments and two wings, each anEntityRefof its own entity id, resolved as any handle is. A part'skindis the dragon's, its position, rotation (none: zero), velocity (zero) andbounding_boxare its own,levelis the dragon's, and it is no living entity, mob or dragon handle; it stops resolving when its dragon does, and removing one is refused. -
entity.nbt(host)andmerge_nbt(host, compound)read and change its data as/data getand/data merge entitydo (merging works on mobs, dropped items, armor stands and item frames; never on players, as in vanilla);tags,add_tag,remove_tagwork as/tagdoes, andnameis its name as plain text.set_custom_name(host, Some(text))names it andNonetakes the name away (Entity.setCustomName);custom_name_visibleandset_custom_name_visibleread and change whether the name shows at any distance. Both writes are saved and reach the players tracking a mob or an armor stand; they return false for a player and for the kinds whose name Horizon keeps nowhere yet (projectiles, primed TNT, falling blocks, experience orbs, ominous item spawners, the dragon and crystals).classificationis the type'sMobCategory, andadded_to_levelis true from whenentity_join_levellet the entity in until it leaves or is removed. -
entity.play_sound(host, sound, volume, pitch)(Entity.playSound: at the entity, under itssound_source, for the players in range; a silent mob plays nothing and a player doesn't hear their own),game_event(host, event)(Entity.gameEvent: the entity is the source of a vibration for sculk sensors, wardens and allays),broadcast_event(host, id)(Level.broadcastEntityEvent: entity eventidto the players tracking it and to itself, forhandleEntityEventto act on),sound_source(host)(Entity.getSoundSource, aSoundSource) andspectator(host)(Entity.isSpectator) work for every kind.game_eventandbroadcast_eventact on the level in place and return false for a player in a parked level. There is nosprint_particles: vanilla spawns those on the client only. -
player.stop_sound(host, sound, source)sendsStopSoundCommand's packet to that player (Nonefor any sound or source),player.play_sound(host, sound, source, volume, pitch)plays a sound for them only at their position, andentity.play_bound_sound(host, sound, source, volume, pitch)plays one that follows the entity for the players in range, asLevel.playSound(null, entity, ...)does./stopsoundand/playsoundsend through the same code. They return false for a stale handle or a player who isn't online. Looping, fading and a handle to a playing sound need client handles. -
living.health(host)isLivingEntity.getHealth(Paper'sDamageable#getHealth),max_healthisgetMaxHealth(players and guests read their attribute map; an armor stand the fixed 20 the game seeds it with — lava does not hurt it, and splash water reads the very field — and the dragon its 200, from their own fields until live attribute maps cover them) andaliveisisAlive(Paper'sEntity#isDeadis its inverse; a dying mob, mid death animation, is not alive). During a nested player swap an outer player's displaced record is unavailable, and during a player's turn the other players' handles readNoneuntil the hub is back. -
living.attribute_value(host, id)isAttributeMap.getValueandattribute_baseisgetBaseValue, for a player, a guest, a mob, an armor stand and the dragon (Nonefor an attribute the entity type lacks or a stale handle). A mob, a stand and the dragon answer from a live attribute map built from the type's defaults, the savedattributeslist (bases and permanent modifiers, with their ids), and the attribute modifiers of the items they hold and wear, put on and taken off slot by slot inEquipmentSlotorder asLivingEntity.collectEquipmentChangesdoes (transient: never saved). The map is built when first needed. Mob behavior still reads the type's defaults, so a modifier does not yet change how a mob moves or hits. An item's modifiers are the stack's ownattribute_modifierscomponent or its item type's, asitem_attribute_modifierleaves them. On a client the reads answer for the local player only, from the client's own copy of the synced instances (ClientboundUpdateAttributes, the type's defaults before the server sends any); every other entity readsNonethere, the client keeping no attribute map of another entity's, only the few fields it draws (handleUpdateAttributesfor a mob extracts its maximum health and scale for the renderer). -
living.attributes(host)lists the ids of the attributes the entity's type has (the holder's attribute set the value and base reads answer from), in registry order.living.attribute_modifiers(host, id)isAttributeInstance.getModifiers: the attribute's modifiers in the order they were added — a mod's transient and permanent ones, the saved list's, and what the worn and held items grant. An attribute no modifier is on answers an empty list;Nonefor an attribute the entity type lacks, a stale handle, or on a client for any entity but the local player. Diverges from upstream: new helpers. -
The writes take players, guests (in their own turn), mobs and armor stands, and answer false for an attribute the type lacks, the dragon (
/attributerefuses it, and nothing reads its attributes yet), a stale handle, or on a client.living.add_attribute_modifier(host, id, modifier, lifetime)isAttributeInstance.addTransientModifieroraddPermanentModifier(ModifierLifetime), refused when the attribute already has a modifier with that id (vanilla throws) or the amount is not finite.replace_attribute_modifierputs the new one in place of the old (addOrUpdateTransientModifier,addOrReplacePermanentModifier), and the new lifetime wins.remove_attribute_modifier(host, id, modifier_id)isremoveModifier(false when there was none), andset_attribute_base(host, id, base)issetBaseValue.AttributeModifierholds the modifier's id, amount andAttributeOperation. A permanent modifier and a base are saved with the entity; a transient modifier lasts until it unloads. The writes take the one path/attributetakes, so they go where the game and the attribute sync read:- a player's own attribute map;
- for a mob, the store of the nine attributes its behavior uses (max health, movement and flying speed, follow
range, attack damage, knockback resistance, jump strength, burning time, reinforcements), so it moves and hits by
them at once; its armor map for armor and toughness, which its damage reads; and for its other attributes, as
for an armor stand, the generic map
/attributereads and the sync sends.
The live map behind
attribute_valuetakes the same write, so the reads agree with/attribute, and the entity's save keeps it. A lower max health cuts a mob's health (LivingEntity.onAttributeUpdated). -
item_attribute_modifier(ItemAttributeModifierEvent) edits what one stack grants:ItemAttributeModifiersholdsItemAttributeModifierentries (the attribute's id, anAttributeModifier, anEquipmentSlotGroup) and edits them as NeoForge's event does:add(addModifier, refused when the attribute has that id already),replace,remove,retain(removeIf),remove_all_forandclear. Every reader of a stack's modifiers asks through one place (horizon_client::item_attributes):- the equipment of a player, a mob, an armor stand and the dragon, when it changes;
- mobs' gear knockback resistance and their pickup choices;
- the attribute sync;
- both tooltips.
A worn stack is asked again only when it changes (
ItemStack.matches), so the answer must be the same for the same stack. With no mod listening, or none changing a stack's list, nothing is copied. A player's attack speed still reads its held item type'sattack_speed, not the stack's (see #549). -
get_enchantment_level(GetEnchantmentLevelEvent) edits the enchantments gameplay sees on a stack:ItemEnchantmentsis NeoForge'sItemEnchantments.Mutable(level,set,upgrade,retainforremoveIf, levels at most 255), andtargetis the one enchantment a read asks for,Nonewhen every one is read. The game's reads follow NeoForge's split:- through the hook:
ItemStack::enchantment_level(getEnchantmentLevel, every gameplay level read: Looting, Fortune and the other loot functions, Respiration, Silk Touch, Mending, Thorns, the trident's and crossbow's enchantments, fishing), andItemStack::all_enchantments(getAllEnchantments: every enchantment effect component, the enchantments' attribute modifiers, the decorated pot'shasTagcheck, the killer's gear for entity loot); - the stack's own component:
ItemStack::enchantmentsandtag_enchantment_level(getTagEnchantmentLevel) for tooltips, the anvil, grindstone and enchanting table, item predicates, saves, the network, andEnchantmentHelper.has(Curse of Binding), which this hook doesn't change.
It is asked on every such read, many times a tick, so keep a listener cheap. With no mod listening nothing runs; with one, ids are parsed once and a read the mods leave alone allocates nothing.
- through the hook:
-
living.set_health(host, health)isLivingEntity.setHealth(Paper'sDamageable#setHealth), cut to the range from zero to the maximum, with no hook; a non-finite value is refused. This raw write can set zero or change a dead entity's health without running the death pipeline.living.heal(host, amount)isLivingEntity.heal, through theliving_healhook: a signed amount changes health only when current health is above zero. Both write players, guests (in their own turn) and mobs, and answer false for armor stands and the dragon.living.last_attacker(host)isLivingEntity.getLastHurtByMob: who last hurt the entity, within a hundred ticks of the hurt, as anEntityRefof the current open;Noneonce the window has passed or the attacker is gone, and for armor stands, the dragon and clients.living.kill_credit(host)isLivingEntity.getKillCredit: it returns the remembered player first, or the last hurting mob if there is no remembered player. A remembered player that cannot be resolved givesNone, not the mob. Player credit resolves across dimensions; the mob fallback currently resolves only in the victim's level. -
An
EntityRefholds the world's open (WorldEpoch) and the entity's id, which no other entity of that open gets. A handle of an earlier open, of an entity that is gone or moved to another level (it gets a new id there, as in vanilla), or used on a client reads asNone. -
The entities are those of the level in place (the one a hook runs in) and the server's players, whatever their level.
-
host.block_entity(level, pos)finds a block entity in a loaded chunk, including in parked levels;kind,position,levelanddataread it. A removed or temporarily taken-out entity readsNone; a replacement at the same position resolves to the new entity, unlike vanilla's object identity. -
player.open_menu(host, block_entity)opens its menu on the server and returns true only when opened. It returns false for stale or wrong-level handles, non-providers, cancelled opens, and players without a connection (including fake players), since no connection can receive the menu. -
block_entity.viewers(host)lists the players with its container open on the server, read from their open menus when called: players in its level, not spectators or fake players, a double chest's menu on both halves.Nonefor a stale handle or a client. -
level.block_shape(host, state, pos, kind)reads a supplied state's block-local outline or collision boxes with an empty context, without changing the world's state; passlevel.block_state(host, pos)for the stored state.Nonefor a client, unknown state, stale handle or closed level; parked levels can be read. -
level.entities_in(host, min, max)lists resolvable entities whose boxes intersect the given box in the level in place; see its method documentation for the kinds and tick-time visibility not yet covered. -
level.pop_resource(host, pos, stack)drops a stack as a broken block does (Block.popResource),spawn_item(host, x, y, z, stack, pickup_delay)andspawn_item_with_motionspawn a plain item entity and give its handle, with the pickup delay vanilla'sItemEntity.setPickupDelaysets at construction (10 a block's drop, 40 a player's throw, 32767 never), andaward_experience(host, x, y, z, amount)splits experience into orbs (ExperienceOrb.award), all in the level in place.
level.set_block(host, pos, state, flags) and level.remove_block(host, pos, moved) change the level in place. They return false for a stale handle, client, parked level, unloaded chunk or out-of-height position; parked writes wait for a safe level swap. Removal preserves the fluid's legacy block; moved marks a piston move. BlockUpdateFlags combines supported vanilla update flags with |. CLIENTS sends the update, while INVISIBLE suppresses notification only on the client, as in Level.setBlock. Skipping block-entity side effects suppresses container-content drops when replacing a chest; without it, the contents spill.
level.destroy_block(host, pos, drop, tool, collect) uses that same level's destruction and loot path.
tool: None means an empty hand. With collect, the returned stacks are not spawned as item entities;
without it, a successful break returns an empty list after spawning them. The normal block-drop hook
still runs once before either outcome; tool enchantments and vanilla random ordering are unchanged.
drop: false suppresses resources. None means nothing broke, including an invalid write or air.
level.set_break_progress(host, breaker, pos, stage) shows stages 0..9 or clears the crack otherwise;
a machine uses a stable negative breaker id.
A helper whose catalog entry names a receiver (entity, level, block_entity) is called on that handle (for example entity.position(host)), rather than as an ID-based Host method.
Client menu input
Client input uses menu.click(host, &[mod_api::MenuClick::QuickMove { slot }]) on a mutable ClientHost.
Actions use menu-order slots; only Pickup accepts None for outside. Typed MouseButton,
SwapWith and DragKind values replace vanilla's wire codes, and Drag { kind, slots } expands
to a complete gesture. All actions are validated before the first is predicted or sent through the
normal ContainerClick path. A stale handle, invalid slot/hotbar index, creative-only action in survival,
spectator, non-client host or creative inventory screen returns false without side effects.
true is submission, not server acceptance; authoritative slot/cursor corrections still win.
menu.close(host) closes the menu's open screen as Escape does (AbstractContainerScreen.onClose,
LocalPlayer.closeContainer, which sends ServerboundContainerClosePacket), and menu.click_button(host, button)
sends ServerboundContainerButtonClickPacket. Both return false for a stale handle or a non-client host; close also
while the menu's screen is not open, and click_button for the inventory menu.
See A menu's slots for actions and an example.
Diverges from upstream: new helpers.
Mob AI goals
crates/horizon-client/src/game/modding/native_mob_goals.rs (#29's "movement and goals"): a mob's AI is vanilla's two goal
selectors, and a MobRef reads and changes them. mob.goals(host) and mob.target_goals(host) list each selector's
goals as a MobGoalEntry — the priority (a lower number preempts a higher one), the kind id (melee_attack) and
whether the goal runs now. mob.add_goal(host, priority, &goal) is GoalSelector.addGoal: the GoalSpec names one of
the vanilla goal kinds Horizon itself runs, with the vanilla constructor's parameters
(MeleeAttackGoal(speed, followUnseen)), and mob.add_target_goal(host, priority, &goal) adds to the target selector.
mob.remove_goal(host, id) and mob.remove_target_goal(host, id) are removeGoal: every goal of that kind goes, a
running one stopped first, and the count comes back; as vanilla's removeAllGoals with a getClass() predicate, a
species variant of a vanilla goal (a bee's BeeHurtByOtherGoal, a labeled stroll) keeps its own name, is read under it
and is removed by it.
The kinds, by their id: float, climb_on_top_of_powder_snow, panic (with water_range for the turtle's),
stroll (water_avoiding, swim and flying pick RandomStroll's variants), look_at_player, random_look_around,
melee_attack, leap_at_target, avoid_entity, tempt (item ids, minecraft:wheat), breed, follow_parent,
follow_mob, restrict_sun, flee_sun, eat_block, breath_air, try_find_water, and the target selector's
nearest_target (with prey and interval) and hurt_by_target (with alert). Species ids in prey, what and
only are namespaced (minecraft:zombie); a bare id reads as minecraft:'s.
An add refuses what the mob's vanilla class does not hold: a PathfinderMob goal for a bat, ghast or phantom,
and breed / follow_parent for a mob that is no animal. Slimes, magma cubes and shulkers are PathfinderMobs.
follow_mob accepts ground or flying navigation, but not water-bound or amphibious navigation. It also refuses
non-finite numbers, negative speeds and distances, a probability outside 0..=1, a
negative interval, an empty items list, and a species id no mob type carries. Reads and removes answer None/false
for a stale handle, a client, the dragon, and a mob whose copy is held out — nothing in a mob's own tick resolves, as
Host is not there yet: change goals in entity_join, which fires for a loaded chunk's mobs too. Goals are not saved,
as in vanilla: after a reload the mob has its species' set again, and a mod's entity_join adds its own back.
These methods reuse built-in goal kinds; they do not define arbitrary goal callbacks or run at construction's
Mob.registerGoals moment. Custom mob goals at construction
tracks a typed goal registrar and callbacks on MobRef and Parts, shared by native and wasm mods. NeoForge has
no bus event at this moment. The upstream raw-Mob register_goals interface is not part of this fork's API.
Mob AI controls
crates/horizon-client/src/game/modding/native_mob_refs.rs and native_mob_navigation.rs (#938's mob AI controls): a MobRef
steers a mob through the engine's own controls, each a momentary wish the mob's own AI may replace next tick, as
move_to's path is. Reads take the host by &dyn Host and writes by &mut dyn ServerHost.
mob.look_at(host, [x, y, z])isLookControl.setLookAt: the mob's look control rotates its head toward the position over the next ticks. Non-finite coordinates are refused.mob.jump(host)isJumpControl.jump: the mob jumps on its next own tick by its own jump path — no velocity is written. No species refuses either: vanilla's brains themselves write through these controls (a brain species'LookAtTargetSinkcalls the samelookAt), so a mod's wish and the mob's own are one flag, not a brain memory a write could miss (which is whyset_targetrefuses brain species, andset_brain_targetwrites theirATTACK_TARGETmemory instead). A creaking being looked at, an angry bee and an axolotl playing dead hold their look control, and a held creaking its jump control, as in vanilla: the wish waits until the control runs again.mob.navigation_speed(host)/set_navigation_speed(host, speed)read and setPathNavigation.speedModifier, the multiplier the current path is followed at (1.0 is the mob's normal speed; 0 until a path sets one) — not the mob's movement-speed attribute.move_to's speed argument sets the same modifier, and the mob's own nextmoveTo(a goal's or a brain species') sets its own again, as in vanilla. Non-finite speeds are refused.mob.restrict_to(host, [x, y, z], radius)isMob.restrictTo(pos, radius): the home area the mob keeps to — its own stroll and wander goals pick their positions inside it, as a lead's and a guardian's home do. It is saved with the mob (home_pos,home_radius).restrict_center(host)(getRestrictCenter; the last live one after a clear,[0, 0, 0]for a mob that never had one),restrict_radius(host)(getRestrictRadius;-1without a restriction),has_restriction(host)(hasRestriction),clear_restriction(host)(clearRestriction; the center stays) andis_within_restriction(host, pos)(isWithinRestriction; true everywhere without a restriction) read and change it. Radius-1inrestrict_toclears the restriction while keeping the position as the center.mob.left_handed(host)/set_left_handed(Mob.setLeftHanded, the mob flags in its entity data, saved asLeftHanded) andcan_pick_up_loot/set_can_pick_up_loot(Mob.setCanPickUpLoot, saved asCanPickUpLoot; an allay and a sulfur cube decide for themselves) moved here from the flatliving_*helpers of #938's first round: vanilla keeps both onMob, soMobRefis their home. The flat helpers are gone, with no aliases.- All of these answer
None/falsefor a stale handle, a client, the dragon (noMobstate), and a mob whose copy is held out of the level.
Mobs drawn by a mod
crates/horizon-client/src/game/modding/native_mob_draw.rs: native_mob_submit(mob, draw, drawn) (client) is asked once a frame for
every mob in view but players (vanilla's EntityRenderer.submit). Set drawn and the mob's vanilla model, armour and held
items are left out (NeoForge's RenderLivingEvent.Pre, cancelled); its name tag, shadow, flames and glowing outline still draw.
mob(MobRender) is the frame'sLivingEntityRenderState: the id, kind, custom name andbaby, the partial tick,age(ticks lived plus the partial tick),body_yaw,head_yaw(from the body) andpitchin degrees, the walk cycle (walk_position,walk_speed: a vanilla quadruped's legs swingcos(walk_position * 0.6662) * 1.4 * walk_speedradians),hurt_time,death_timeandon_ground.drawis a block entity's frame (native_block_entity_submit): draw withnative_draw_cube,native_draw_quad,native_draw_blockandnative_draw_itemunder its pose stack, which starts at the mob's feet turned with its body: +z is the way it faces, +x its left, +y up, in blocks. Turn a head or swing a leg withrotate_aboutand a pivot. It holds the mob's light.- A mod's own creature is an ordinary mob (
native_spawn_mob) drawn this way: a quiet villager, an allay or a bat gives it movement, sounds and saving, and the mod gives it a model and animations of its own. Tell yours apart by the ids the server half sends with mod messages, or by its custom name. - Players have their own hook,
native_player_submit(player, who, draw, drawn)(RenderPlayerEvent.Pre): once a frame for each other player in view and for this window's player in third person, with the same frame andwho(the profile name, andlocal).drawnleaves out the vanilla model, armour, held items, cape and other layers. native_mob_layer(mob, who, draw)(EntityRenderersEvent.AddLayers,RenderLivingEvent.Post) adds to every mob and player after its vanilla model and layers, in the same frame (not for one a mod drew itself);whois set for a player. The geometry hangs from the entity's frame, not from a model bone: place a hat fromhead_yaw,pitchand the entity type's eye height.
Advancement triggers
crates/horizon-client/src/game/modding/native_criteria.rs: a mod's own criteria triggers, as a NeoForge mod registers a
CriterionTrigger and calls its SimpleCriterionTrigger.trigger. The advancement is an ordinary file of a content mod
or a world's data pack (data/mymod/advancement/connect_a_pipe.json) whose criterion names the mod's trigger and
whatever conditions the mod reads:
{
"criteria": {
"copper_pipe": {
"trigger": "mymod:pipe_connected",
"conditions": {"pipe": {"items": "#c:ingots/copper"}}
}
}
}
player.trigger_criterion(host, &trigger, test)fires it on aPlayerRef, with a mutableServerHost, a mod-ownedIdentifiersuch asmymod:pipe_connected, and anArc<dyn CriterionTest>. Each criterion with that trigger the player hasn't been granted, of an advancement not done yet, goes totest, then to itsplayerpredicate, as vanilla tests one. Those that pass are granted; an advancement they complete is awarded as by the game's own triggers: the toast, the chat line, the rewards and the player's saved progress.CriterionTest::test(&self, criterion: &dyn CriterionContext)returnsResult<bool, CriterionTestError>. The context is a scoped game resource, borrowed only for this test; neither it nor its private world can be retained.criterion.conditions_json()gives owned JSON ("null"when conditions are absent).criterion.item(key, &stack)tests the item predicate underkeyas the game's own triggers read one:items(an id, a list or a#tag),count, thepotion_contentsanddamagecomponents, and theenchantmentsanddamagepredicates. Other components and predicates aren't checked. A missing key passes. Tag membership is evaluated in the target player's world, not from a detached conditions snapshot.- It reaches the acting player inside their hook, and another player on the server in their own level, including
during a guest's turn. A displaced outer player is unavailable.
Ok(())means the player was reached, not that anything was granted.TriggerCriterionErrordistinguishes an invalid trigger (minecraft:is reserved; empty namespace/path is refused), a remote client, an unavailable player, a missing game backend, and a failed test. A failed test stops further tests; earlier matching criteria may already be granted. The test itself is not retained. - Both resource interfaces and their glue are generated from the traits. Native and wasm predicates execute
synchronously through the same criterion context. Wasm callbacks into the triggering group borrow the import's
store while the outer call retains its group lock; fuel or epoch deadlines are shared, never refreshed for a
callback, and the memory limit remains in force. The nested context expires on return and the outer call's frame
is restored. A stopped or unavailable group returns
CriterionTestError::Unavailable, propagated asTriggerCriterionError::Test, rather than silently treating the criterion as a nonmatch.
Diverges from upstream: this replaces the unscoped Host closure helper with a player receiver and generated
criterion resources; the private advancement world remains solely in the native implementation.
Explosions
crates/horizon-client/src/game/modding/native_explosions.rs: a helper for server mods, for the level in place, on a server only. It
names the level it means by its dimension (minecraft:overworld), as native_set_block does, and is refused when another
level is in place.
LevelRef::explode(host, center, radius, fire, interaction)sets off an explosion asLevel.explodewith no source entity does: blocks break (native_explosion_detonatesees them first and can spare some), entities are hurt and pushed,firelights some of the surrounding air, and nearby players hear and see it.interactionisExplosionInteraction::None(no block breaks),Block(drops byblock_explosion_drop_decay) orTnt(bytnt_explosion_drop_decay). It answers false, doing nothing, for an unavailable level, a chunk that isn't loaded, or a radius outside0 < radius <= 32(TNT is 4, vanilla's largest is 7).- A blast set off inside
native_explosion_detonateruns that hook again, so a mod that chains blasts keeps its own guard.
Camera shake
native_camera_shake(partial, shake) (client) asks every frame for the camera shake a mod wants: pitch, yaw and roll
in degrees and an offset in blocks along the camera's right, up and back. Add to shake rather than set it, so two
mods' shakes add up. Horizon reads it only while playing, scales the sum by Distortion Effects (a player who turns
that to 0% sees no shake), bounds it to 10 degrees and half a block on each axis, and multiplies it into the view
after the hurt tilt and view bobbing. A mod keeps its own shake's time and decay, from partial and its tick hooks;
it moves only what the player sees, not where they are or aim.
A player's motion
crates/horizon-client/src/game/modding/native_motion.rs: a push on a client.
native_push_player([x, y, z])(client) adds a push to the player's own velocity, in blocks a tick, as an explosion's knockback does: the player's own game moves them. The server didn't send it, so on a LAN or dedicated server the anticheat's movement check sees a move it can't explain (logged only unless the server sets it to send players back); for a push the server should stand behind, have the server send it. Each component is cut to 4 either way; an upward push lifts the player off the ground. A spectator, no player, or a value that isn't a number is left alone. A server mod that wants a client pushed sends its client half a message.
Values for a mod's shader pack
native_shader_params(partial, params) (client) asks every frame, while a Horizon shader pack is on, for eight
[f32; 4] that every stage of the pack reads as frame.mods (array<vec4<f32>, 8>, docs/api/shaders-reference.md). What
they mean is the mod's own: a mod that bundles its pack as native-<mod id> writes the slots that pack reads, such as an
explosion's centre (camera-relative, from frame.camera_position) and its age in seconds, for a cloud or a shock wave
ray marched in a post pass against scene_depth. A value that isn't finite reads as 0, and all are 0 when no mod sets
them. The mod's server half can send what happened to each client half with mod messages.
Biome colours
crates/horizon-client/src/game/modding/native_biome_colors.rs: the grass, leaf and water colours a client mod changes while the
game runs, for seasons (a golden autumn, a pale winter) or a world that greens as it heals.
native_biome_colors(biome, colors)(client) changes a biome'sBiomeColors:grass,foliage(leaves, vines),dry_foliage(leaf litter) andwater, 0xRRGGBB, starting from the biome file and the colormaps. The dark forest's modifier still darkens the grass set here; the swamp's noise colours replace it.- It is asked for every biome when a mod calls
native_refresh_biome_colors(). When the colours differ from those in use, every loaded section and the far terrain are rebuilt with them, as changing Biome Blend does. Call it when your colours change and once when the player joins, not every tick: a rebuild takes the workers a few seconds. A slow fade is a call a day, not a frame. - Block-breaking and falling-leaf particles keep the biome's own colours for now.
- A season's mood in a shader pack (warmer grading, a lower sun, more haze) comes from the same mod through
native_shader_params(frame.mods); its server half can send the season to each client with mod messages.
Sounds a client mod plays
ClientHost::play_local_sound(sound, after) (client) plays sound, a SoundPlay, for this player only. Nothing is sent
to the server, and other players don't hear it. It takes &self, so the screen, key and mouse hooks, where interface
sounds belong, can call it: the sound is queued and starts at the next audio update (once a client tick), after ticks
counted from then (0 to 1200; SoundEngine.playDelayed). The answer is exact: the checks run at the call.
SoundPlay::ui(id)is an interface sound (SimpleSoundInstance.forUI): at the listener, on theUisource, not fading with distance, as the game's own button clicks play.SoundPlay::at(id, source, position)plays at a place in the client's level, fading with distance. A volume past 1 carries farther, as vanilla's attenuation does.with_volume,with_pitchandlooping(delay)(repeating,delayticks between repeats: vanilla'sSoundInstance.getDelay) change either.
It's the same record the play_sound hook edits, and it goes through the same mapping into the engine. The event is
vanilla's (minecraft:entity.generic.explode) or the mod's own: a content mod's assets/<id>/sounds.json and
assets/<id>/sounds/*.ogg load with the rest of its assets. A client half that hears from its server half (mod
messages) where something happened can time the sound to the distance, such as a blast heard when its shock wave reaches
the player: after = distance / speed. It answers None on a server and for a value out of range (an event id that
isn't namespaced, a position or volume that isn't finite, a negative volume, pitch or repeat gap, after past 1200);
an event the resources lack plays nothing. Diverges from upstream: horizon-rust/horizon's native_play_local_sound takes
(sound, at, volume, pitch, delay).
Otherwise it answers a SoundRef, a handle on this one play, never reused. Its methods take the &dyn ClientHost, so
any client hook can call them. Like the play, the writes are queued for the next audio update, while the answers are
computed at the call:
sound.state(host)isSome(SoundState):Waiting(queued, waiting outafter, or between a looping sound's repeats),Playing(SoundEngine.isActive) orStopped. It isNoneon a server.sound.stop(host)stops it for good: a waiting sound never starts and a looping one never repeats. A stop asked since the last update already readsStopped.sound.fade(host, volume, ticks)moves its volume tovolume(at or above 0) in equal steps over the nextticksunpaused ticks it plays, or at once for 0. A fade to 0 stops it when the fade ends, asLoopSoundInstance.fadeOutdoes. ASoundPlayat volume 0 faded up fades in, and a later fade replaces one in progress. While the game is paused the fade waits, as vanilla's tickable sounds do (SoundEngine.tick(paused)): an interface or music sound keeps playing at the volume it had (pauseAllExcept(MUSIC, UI)), and any other sound pauses with the game.
stop and fade answer false, changing nothing, once the sound is over. Once it ends, is stopped or faded out, or is
cleared (leaving or changing levels stops every sound), the handle reads Stopped and its writes do nothing. It stays
valid across a change of output device, which reopens the output on the same mixer and keeps the sounds playing.
Dropping or overwriting the handle doesn't stop the sound: keep a looping sound's handle while it plays (stop it before
starting another in its place), or nothing can stop it until the player leaves the level.
ClientHost::sound_events() lists every sound event the client's resources have (SoundManager.getAvailableSounds).
audio_devices() lists the output devices by name (SoundEngine.getAvailableSoundDevices), and audio_device() names
the one the options ask for, empty for the system's default. Both lists are built anew at each call, and listing the
devices probes each one: ask from setup or a settings screen, not every frame.
Falling blocks
crates/horizon-client/src/game/modding/native_falling.rs: a helper for server mods, for the level in place, on a server only. It
names the level it means by its dimension (minecraft:overworld), as native_set_block does.
native_spawn_falling_block(dimension, state, pos, velocity, falling)throws a falling block ofstatefrompos(its bottom centre) atvelocityin blocks a tick, each axis cut to 3.9. It flies, lands and places itself as sand does, or drops as an item where it can't be placed.Fallingholdsdrop_item(default true) andhurts(hurts what it lands on as an anvil does, 2 a block fallen and 40 at most; default false).- The block at
posstays: take it away first withnative_set_blockto make a block fall out of the world, as an explosion's debris or a collapsing tower would. - It answers false, throwing nothing, on a client, for another dimension, air or an unknown state, a chunk that isn't loaded, a value that isn't finite, or with 1024 falling blocks in the level already.
Bulk block edits
crates/horizon-client/src/game/modding/native_world_edit.rs: a helper for server mods, for the level in place, on a server only.
It names the level it means by its dimension (minecraft:overworld), as native_set_block does.
native_edit_blocks(dimension, edit)makes many blocks at once:BlockEditholds apaletteof block states and a list of typedStatePredicatefilters (exact blocks or tags), andruns, eachheightblocks up column (x,z) fromy, set to palette entryblock, only where filteronlypasses the block there. Withsurface(Heightmap:WorldSurface,MotionBlocking,MotionBlockingNoLeavesorOceanFloor),ycounts from that height of the column, read once a column before anything changes. Runs are made in order, each seeing the changes of those before it.- The blocks go straight into the chunks, a section packed once however many of its blocks change, as
fill ... strictdoes: no neighbour is told, nothing drops, and no block updates or ticks; light is worked out again for the chunks changed, and they go to the players whole. A block replaced by another loses its block entity, and a new block gets the onenative_set_blockwould give it (a mod block's own, included). - It answers the number of blocks changed. Runs in chunks that aren't loaded, and the parts of runs outside the level's
height, are left out. It answers
Err, changing nothing, on a client, for another dimension, a palette entry or filter or predicate that fails validation (NBT is not representable), a run naming a palette entry or filter past the end, or more than 65536 filters.
Column reads
crates/horizon-client/src/game/modding/native_world_columns.rs: helpers for server mods, for the level in place, on a server
only, that read many columns in one call. They name the level they mean by its dimension (minecraft:overworld), as
native_set_block does, and answer Err with the reason, reading nothing, on a client, for another dimension or for
a read too big, so a mod can tell "ask for less" from "wrong level". They run on the server tick: read a big area in
pieces.
native_surface_heights(dimension, columns, heightmap)answers each column's height onheightmap(Heightmap, asnative_edit_blocksreads it: one above the highest block that counts),Nonefor a column whose chunk isn't loaded; at most 65,536 columns a call.native_read_columns(dimension, columns, heightmap, (below, above))reads, for each column, the blocks frombelowunder its height toabove- 1 over it, bottom first:ColumnBlocksholds the heights (bases), the block completeBlockStatevalues seen (palette), the blocks as indexes into it, and three biome tints a column (grass, foliage and water, at the column's top). Blocks outside the level's height, and every block of a column not loaded, read as air (its tints as 0).below + abovemust be 1 to the level's height, and the read at most 1,048,576 blocks. The tints are the biome's own at the column's top, not blended over the biome blend radius as the game draws them, so near a biome border they can differ from what players see by a few blocks.
Mod messages
crates/horizon-client/src/game/modding/native_net.rs: bytes between a mod's server half and its client half, on the
one custom payload channel horizon:mod_net. A vanilla client never gets them, and a vanilla server isn't sent any.
native_net_send_to_player(player, channel, data)on the server andnative_net_send_to_server(channel, data)on the client send one. The channel ismod_id:name: the id[a-z0-9_], the name[a-z0-9_./-], each 1 to 64 characters;minecraftandhorizonare the game's. The data is at most 30 000 bytes, and 32 messages a tick go each way per player. Ok means accepted: a player who left doesn't get it. Sending to the server is an error until the player has joined.native_net_from_client(player, channel, data)andnative_net_from_server(channel, data)hear them, all mods on every message: check the channel. What a client sent is checked by nothing, so treat it as a player's own input.- A player's message reaches the server's hook in the tick after it arrives, when the helpers can reach every player; a message to a player leaves at the end of the tick, from any server hook.
Chat and commands a client mod sends
ClientHost::send_chat(message) sends what the player could type in chat, to any server, a vanilla one included:
ClientPacketListener.sendChat and sendCommand. A leading / sends the rest as a command, signed where its
arguments are messages; anything else goes as signed chat. The message is trimmed first, as the chat screen trims it.
It fires no client_chat (vanilla's ClientChatEvent comes only from the chat screen) and isn't added to the chat
history. It is an error when the player isn't in a server's play state (not connected, not joined yet or being
reconfigured), when the trimmed message is empty, and when the Chat option blocks it: Commands Only blocks chat,
Hidden blocks chat and commands. The server's own spam limits apply as they do to typed chat.
Server menus
crates/horizon-client/src/game/modding/native_menus.rs: menus a server mod builds from ordinary items, which a
stock client shows as a chest (generic_9x1 to generic_9x6); nothing is installed on the player's side.
native_open_menu(uuid, rows, title, items)opens one in place of the player's open menu (its window id); its slots are menu slots, written in a transaction;native_close_menu(uuid)closes it.- No click moves anything. Each reaches
native_menu_clicked(uuid, window, slot, button, mode), and the client is sent the menu, its inventory and its cursor as they were.slotcounts the menu's own slots first, then the player's inventory below it:native_menu_inventory_slot(size, slot)gives the inventory slot (0..9 the hotbar). native_menu_closed(uuid, window): the player closed it, another menu opened, or the player left.- A trade on these menus leaves the offered stacks in their owners' inventories until both sides are settled. Then,
in one
host.transaction, each offered inventory index is checked to still hold exactly what was offered, taken out of the player'sENTITY_ITEMShandler and put into the other's withinsert_any(the recipe). A changed offer or no room returns an error, and every move is put back.
Render hook points
crates/horizon-mod-render/src/render.rs: native-only, unstable raw frame places, outside mod_api.
Use [[inject]] with access = "internals", kind = "head" and a checked listener signature at
horizon_mod_render::render::<name>. They run on the render thread with the frame's wgpu objects;
what a mod draws there shows in that frame. Draw contexts remain the API-typed drawing route.
world_drawn(world): the world is drawn (sky, terrain, entities, particles, weather and the world post pass), the first-person hand, the HUD and screens not yet.WorldDrawnholds the device, queue and encoder, the frame's colour target (copy it out before sampling it), the world's reversed depth and its size, the camera's position and its matrices with and without TAA's jitter, the frame's camera-relative entity quads with their runs by entity, the entity atlas, the shared quad index buffer and the block sprite arrays (block textures). Not called on the title screen's panorama.motionsays which mob, player or dropped item each run of the entity quads is, by its entity id, so a mod can match an entity's vertices with last frame's (a motion blur's own motion vectors);spansonly name the type.hand_drawn(hand): the first-person hand and held item are drawn, afterworld_drawnand before entity outlines, post effects, screen effects and the HUD, on every frameworld_drawnruns.HandDrawnholds the device, queue and encoder, the frame's colour target with the hand in it, the hand's camera-relative quads (empty when no hand drew: third person, F1, a shader pack's own hand), the hand's clip matrix (its own field of view and the view bob, no jitter), the camera, the entity atlas and the quad index buffer. The hand's depth isn't kept: draw its quads into a depth of your own. A motion blur can match the hand's quads with last frame's for the swing, attack, equip and bob motion, and blur the picture with the hand in it.render_needs(needs): setentity_spansto have the entity geometry split into runs by entity without a shader pack (each run's id is stable from frame to frame: a hash of the entity type's name, a block entity's state), andworld_depthto keep the world pass's depth when nothing else needs it. Set what you need and leave the rest: several mods can ask. It's asked as each frame is built, so keep it cheap.
Block textures
crates/horizon-client/src/game/modding/native_block_sprites.rs and WorldDrawn::block_sprites: for a client mod drawing
blocks in its own pass (flying debris as voxels, say) with the player's own block textures, resource packs included,
as the terrain samples them.
WorldDrawn::block_sprites(BlockSpriteArrays) holds the block sprite arrays asD2Arrayviews: the terrain's eight, a 1-layer stand-in past the ones in use, each with the mip levels the Mipmap Levels option leaves, animated layers at the frame shown. Each texture is a whole square layer ofsizetexels, its UVs 0 to 1 across it, gamma-encoded as the world uses it.splitlayers go to an array (2048 on most GPUs, fewer where the GPU holds fewer): every vanilla texture is usually in array 0.nearestsamples texels sharp (nearest, repeating, the nearest mip level),filteredis the terrain's own sampler.native_block_sprites(blocks)says, for each block (minecraft:grass_block,oak_log[axis=x]; properties left out take the default), what its model draws on each side, inDirectionorder (down, up, north, south, west, east): its faces in the order drawn, each with itssprite(array,layer,animated,cutout), itsrecton the side andinsetfrom the side's plane (a whole face is 0, 0, 1, 1 and 0), itsuvmap, itstintsource and whether it istranslucent; the model'sparticlesprite; whether it is a plaincube. None for a block the game doesn't know. A side's axes run along x then z for down and up, x then y for north and south, z then y for west and east, from the block's corner nearest 0, 0, 0 (block_sprites::side_axes); the point (a, b) of a side shows the sprite at(uv[0], uv[1]) + a * (uv[2], uv[3]) + b * (uv[4], uv[5]), rotations and mirrors included (for a plain block's north side,u = 1 - x, v = 1 - y). A tinted face is multiplied by its source's colour:Grass,Foliage,DryFoliageandWaterare the biome's where the block is,Fixeda constant (spruce leaves, redstone at its power). A grass block's side is two faces, the side and then its grey overlay tinted with grass. The model is the state's first random variant; faces that don't lie flat on a side (a flower's crossed planes) are left out, and theparticlesprite stands for them. Fluids give their still texture on every side.native_texture_sprites(textures): a texture's sprite by name (block/stone), none for one no model uses.native_gui_sprite(sprite): a GUI atlas sprite'sAtlasRegionbyIdentifier(minecraft:gui/sprites/container/slot,minecraft:particle/flame,mymod:gui/panel), its UV rect and its size in its own pixels, none for one the atlas lacks. Plain data, right until the nexttexture_atlas_stitchedforTextureAtlas::Gui.native_block_sprites_version()changes each time the resources load (a resource pack turned on or off moves the layers, and the arrays are new): ask again then.
Look blocks up once and keep what you need (a few words a block for your own shader), not every frame.
Menu slot rules
menu_slot_rules asks the registered vanilla or mod menu's scoped MenuPart whether a slot takes a stack and how many it holds, on both client prediction and server. Mount inventories use horizon:mount_inventory; unregistered player inventory and creative picker menus do not dispatch. slot is the backing container index. The slot's own answers arrive first; the final maximum is clamped to 1–99 and the item's stack limit. Rules also apply to quick-move top-ups.
The hook runs during clicks, drags and shift-clicks in the restricted loop context, without a level or player; answer from the stack and context alone, and fast. It runs on the server and during client prediction. The context carries the resource generation's side, registries and settings; /reload builds the next context.
Shift-click in a mod container
native_menu_quick_move(pos, entity, container_slots, slot, stack, handled) is a mod container's
AbstractContainerMenu.quickMoveStack, the method a Java menu overrides to say where a shift-clicked stack goes. It is
asked on the server before the game's own routing, for the mod block entity at pos whose menu a player shift-clicked
with the left or right button, on a vanilla menu type (minecraft:generic_9x1...) or on the mod's own menu type. slot
is the menu's index: below container_slots the block's own slot (entity.items[slot]), from there the player's
inventory, its 27 main slots, then the hotbar. stack is a copy of what that slot holds. Take what you move out of
stack (into entity.items, or into another block through its ITEMS capability) and set handled: the slot holds what
is left of stack (whatever you wrote to that slot of entity.items), the game's routing is skipped, and the player's
client gets the whole menu again. Unhandled, entity.items goes back to what it held and the game moves the stack as
for any container; a later mod sees what an earlier one did to stack. If the mod removes its own block, the menu
closes and the player's inventory is sent again. This is how a storage terminal
whose items live in other blocks, or a screen that hides the container's slots, takes a shift-click from the
inventory, and how a machine sends fuel to its fuel slot and ores to its input.
Not asked for an empty slot, a slot the mod's screen hides, another click type, a spectator or a dead player, a menu
no longer valid, or a shift-click during a drag (it only ends the drag, as in vanilla). It is asked once per click, where vanilla asks quickMoveStack again while the same item keeps moving: move
all you want in one call. The clicking player's state is swapped in (host.players().acting_player names them), and entity
is out of the world during the call, as in native_menu_button. Only a server's players (single player's server
thread, LAN guests, a dedicated server) shift-click this way; a native mod's own menus (native_open_menu) get their
clicks already.
The loop context
dyn LoopContext is the read-only view for a hook fired inside a menu, recipe or
player-list loop, where no game is at hand (a catalog entry names it with
"context": "loop"; menu_slot_rules, item_stacked_on_other, player_name_format,
player_tab_list_name_format, living_jump_power, living_speed, living_flying_speed, living_heal, living_breathe, living_drown, living_fall, farmland_trample, living_jump, ender_entity_teleport, fluid_flow, player_stays_on_ground_surface, block_jump_factor, block_speed_factor, ingredient_test and the sound hooks below take it). side() is LoopSide::Server or
LoopSide::Client, registry(name) borrows names in registry ID order,
setting(mod_id, key) returns a loaded mod's setting captured when the context was
built, and mods() is the LoadedMods view of registration. It has no game Host,
handle construction or transaction, and its answers never change while it is held.
The side that owns the loop builds it, never a hook: the client builds one at window
start and again after each resource reload, and a server builds one when it starts its
mods and again on each /reload, so a reload captures fresh settings. A LAN or dedicated server's hub holds the server's
current one, so its Tab list and chat names, and the clicks of its players, see it too.
item_stacked_on_other(context, event, canceled) is NeoForge's ItemStackedOnOtherEvent, fired inside a pickup click on
either side. The event no longer carries a side: context.side() says it. player_name_format(context, username, displayname) and player_tab_list_name_format(context, player, name, display_name) format a player's name for chat and
commands (both sides) and for the Tab list (server). All three are contract version 2: a mod written for 1 takes the
context as a new first argument of the Hooks method, and reads the side from it instead of event.side.
native_ingredient_test(context, kind, data, stack, accept) is the match-time half of a mod's own ingredient kind: it runs when recipe matching tests a concrete stack against the kind, with accept already true. context is the loop context of the side that matches: the server's for the server's crafting grids, furnaces, stations and recipe tables, the client's for the client's own grid prediction, recipe book and ghost recipes. It fires inside recipe matching, so it takes no Host (a deferred call would reorder matching). Matching passes the context explicitly from the game or menu environment that holds it; there is no global. A mod reads an ingredient with Ingredient::test(host, stack) (&dyn Host, as handle reads do): the game answers with its own loop context, a mod never builds one, and the old test(stack) is gone. Ingredient is plain data. ingredient (load, DataLoadContext) and ingredient_test stay separate hooks and the load path is unchanged. Contract version 2: a mod written for 1 takes the context as a new first argument of the Hooks method. With no listener the cost is one listened branch per test; the context is one pointer, with no allocation. Recipe matching tests every slot change on both sides (per tick for a furnace) and on each click.
Sound hooks
Four client hooks take the LoopContext from the sound engine, which has no game at hand and plays sounds from its own loops:
play_soundisPlaySoundEvent. It fires at the start of vanilla'sSoundEngine.play, before the engine's own checks, for every sound the client plays.originalis the sound as asked for andsoundis&mut Option<SoundPlay>, a copy of it that later mods see edited: change its volume, pitch, source, position, identifier, looping, delay, attenuation orrelative, or set it toNoneto play nothing. A changed identifier is resolved again, with its own weighted pick. Only the fields ofSoundPlaycarry over; the engine's random, ticking and resolved sound never leave it. SettingNoneis the mod's own answer (there is no constant): a sound nobody edits plays as before.play_sound_sourceisPlaySoundSourceEventandplay_streaming_sourceisPlayStreamingSourceEvent. A sound fires exactly one of them, after the engine started it on a channel: streamed sounds (music, records) the second, every other sound the first. They are notices, andChannelRefis an id only: a mod cannot drive the channel. A mod that needs channel control names the mixin it replaces.sound_engine_loadisSoundEngineLoadEvent. It fires when the output is open: at window start, after each resource reload (with the new generation's context) and when the output device changes. With no audio (no device, orHORIZON_NO_AUDIO) the engine never loads: this hook and the two source hooks do not fire, whileplay_soundstill does, since it comes before the engine's check that it loaded.
All four early-out on the mod's subscription before building a SoundPlay, so a game with no listener pays one branch per play. They are client-side only: a server has no sound engine and builds no context.
Items' default components
Hooks::modify_default_components receives &dyn DataLoadContext before its
ModifyDefaultComponentsEvent. The engine supplies this read-only phase context:
origin() is DataLoadOrigin::ServerDataLoad or ClientPreload, registry(name)
gives the names in registry ID order as a shared Arc<[String]> (no copy), setting(mod_id, key) returns a loaded mod's
setting captured when the owning context is built (including missing keys), and
mods() gives the same LoadedMods view used during registration (shared; cloning it
copies no package). A later owning
load captures fresh settings even when it reuses registry inputs; runtime Host
settings still read live storage. There is no game Host, handle construction or
transaction. The origin describes the actual owning load, not its thread name or
the side of a later reader. The raw-injection hook_version is 2.
The lower firing target is native_modify_default_components(context, item, components)
in crates/horizon-world/src/item_defaults.rs (NeoForge's ModifyDefaultComponentsEvent).
components is the item's entry in items.json, vanilla's default-component report:
insert or replace a key to set a component ("minecraft:max_stack_size": 16), remove it to take the component away.
Any item can be changed, vanilla's included. Write values as vanilla's codec writes them: nothing decodes them with it.
Each table reads the fields it understands and takes a default for a field it can't read, but keeps the component, so a
malformed minecraft:food still makes the item edible, with no nutrition. A stack size above vanilla's 99 is kept: the
data pack's own is held to ExtraCodecs.intRange(1, 99), a mod's to 1 to i32::MAX (the count the wire and the save
carry), and a value that is not a number leaves the data pack's own. Keep a stack above 99 to a server whose clients run
the mod: a vanilla client decodes the count with vanilla's 1 to 99 and cannot show it.
- One owning load decodes the defaults, asks once per item, then publishes one immutable snapshot. The item table, loot, enchanting, item models, banners, pot sherds and rarity readers borrow that snapshot; workers retain shared ownership. The renderers also share its canonical component prototypes. Stacks are saved and sent as their difference from the changed defaults, as from vanilla's.
- Reusing client-preloaded item data on the integrated server is not another load. Opening a world or running
the data-pack
/reloaddoes not replay this hook. A dedicated resource load suppliesServerDataLoad; the client preload suppliesClientPreload. The hook may run on a load worker, before a game Host exists. - Client and server each apply their own mods, so both need the mod. A client without it (a vanilla Java client, or Horizon without the mod) fills in vanilla's defaults for the same stacks: a stone stack the server caps at 16 still looks like it stacks to 64 there.
- The creative search tab matches vanilla's exported tooltip text, so a changed
item_nameis not searchable yet.
Counterparts
A hook's optional neoforge catalog key names its NeoForge 26.3 counterpart;
that counterpart sets the hook's name, side and cancellation contract. The optional
fabric and paper keys name Fabric and Paper events at the same moment with the
same contract. Each key is a string or a list of strings; omission is valid. One
hook serves each matching moment; near matches with different timing, side or
cancellation are gaps, not counterparts. A hook with none of these keys names the
mod mixin or use it replaces in its design or PR.
Left to do
- A list of every native build, to delete the ones no instance needs.
- A prebuilt horizon-mixins from its CI instead of building it on the player's computer.
More hook capabilities
-
native_entity_join_level(dimension, entity, kind, pos, loaded_from_disk, canceled), an entity joining a server level (NeoForge'sEntityJoinLevelEvent, vanilla'sPersistentEntitySectionManager.addEntity):loaded_from_diskis true when it comes back with its chunk, false for a fresh add (a drop, an orb,/summon, an arrival through a portal into another level). The entity is already in the level's lists when the hook runs;canceledtakes it back out, and a/summonof it fails with vanilla's "duplicate UUIDs" message. It tells every kind in the game's entity lists (items, orbs, mobs, boats, minecarts, armor stands, primed TNT, falling blocks, projectiles, rockets, tridents), once each, in creation order, so checkkind. A drop, an orb, a mob, a/summonand a chunk's entities are told at once; other gameplay spawns when the next tick starts, and a cancelled one is removed then. -
A block's items are available to server mods through the item capability and
move_resources, so a pipe or machine can move items transactionally. -
What a server mod needs for a public server's screens:
native_sidebar(uuid, sidebar)shows one player a sidebar of up to 15 lines (a text, and one where the score would be) that only their client holds (the server's scoreboard never sees it; a mod sends it again when they rejoin), or takes it away withNone;player.tab_list(host, header, footer)sets the lines over and under their Tab list;native_tick_stats()reads the server's tick rate and average tick time in ms (/tick query's); andplayer.set_sleep_ignored(host, ignored)leaves a player out ofplayers_sleeping_percentage's count until they are in bed (players away from the keyboard). It refuses when the server list is unavailable. Read where they look through each handle's entity. -
Damage a mod can change or cancel through
native_living_incoming_damage(NeoForge'sLivingIncomingDamageEvent): whereLivingEntity.hurtServerstarts, once its guards passed (invulnerable, dead or dying, fire with fire resistance; Horizon's mobs don't check the fire resistance effect there yet), on every damage route, with the hit's level host (its blocks, random and own attachments), the hurt entity's id and type (minecraft:playerfor a player), the hit's source (HurtSource: the damage type,minecraft:fall; the causing entity,causing, vanilla'sDamageSource.getEntity: a skeleton for its arrow; the direct entity,direct,getDirectEntity: the projectile's entity id when the damage type names one, for hits on players, mobs and the ender dragon, including an ownerless projectile, or the attacker itself in melee;magic()and any other source with no direct entity stay 0 even during that projectile's impact; andposition,getSourcePosition), thevictim's position and view vector, the direct entity'sweapon(getWeaponItem) when Horizon knows it (a player's or mob's melee hit inside its attack), and the hit'sDamageContainerbefore any reduction. The entity ids are 0 for none, and for the sources listed below that Horizon doesn't track yet. A mod may change the container's new damage or the invulnerability ticks a fresh hit leaves (vanilla's 20), or setcanceled: nothing ofhurtServeritself happens then (no damage, hurt sound, knockback, invulnerable time, statistics or criteria), and the caller acts as on a refused hit, so a blast still pushes a mob whose hit was canceled, as vanilla pushes one that refused it. A canceled hit on the ender dragon still counts as landed, as vanilla'sEnderDragon.hurtreturns true whateverreallyHurtdid: the arrow is spent and the melee effects run. A ghast's canceled hit by a fireball it was sent back counts as landed (Ghast.hurtServer). A negative amount hurts by 0 and a NaN or infinite one byFloat.MAX_VALUE, as vanilla clamps them. It fires for the players, mobs and the ender dragon the game hurts, and runs on the thread that ticks the world (the server thread), so state a mod shares with its render thread hooks needs synchronising./killandnative_kill_mobdon't reach it yet: they set the health to 0 instead of hurting withminecraft:generic_killas vanilla does. A mod may also register reduction modifiers on the container (container.add_reduction_modifier(Reduction::Armor, ReductionChange::Scale(0.5)), NeoForge'saddReductionModifier): each changes what one reduction (the damage cooldown, armor, Resistance, protection enchantments, a witch's innate resistance, the absorption hearts) takes off this hit, in the order added, and the recorded reduction and the damage the sequence goes on with are the changed ones, kept between 0 and what there was to take. A step the hit skips (a source that bypasses armor, no Resistance effect) runs none. They last for the hit. -
living_damage_post(NeoForge'sLivingDamageEvent.Post) observes the hit after it takes health. Itscontainer.should_cause_side_effects()ishurtServer's fresh-hit decision (shouldCauseSideEffects), recorded before damage is applied: true for a fresh hit, false for a stronger hit inside the invulnerability window, and true for a source that bypasses the cooldown. A weaker or equal hit refused by the cooldown does not fire Post. The flag is not inferred from final damage or invulnerability ticks, so changing those in a damage listener does not change it. It accompanies the container's reductions, inflicted damage and health damage in native and wasm listeners; it describes the decision to cause fresh-hit effects, not whether every effect occurred.
A player's damage type is the source's own. A mob's and the dragon's come from the kind of damage Horizon keeps for the hit, which doesn't tell every vanilla type apart yet:
- A trident's hit on a mob is
minecraft:arrow, notminecraft:trident - A wither skull's hit on a mob is
minecraft:magic, notminecraft:wither_skull - Evoker fangs on a mob are
minecraft:magic, notminecraft:indirect_magic - A fireball nobody shot is
minecraft:fireball, notminecraft:unattributed_fireball - A shulker bullet's hit has no
minecraft:mob_projectile - A bed or respawn anchor blast on a mob is
minecraft:explosion, notminecraft:bad_respawn_point - A blast from TNT a player lit is
minecraft:explosionon a mob, notminecraft:player_explosion - A bed or respawn anchor blast on the dragon is
minecraft:explosion, notminecraft:bad_respawn_point - A blast a mob caused (an end crystal a skeleton shot, a creeper's) is
minecraft:explosionon the dragon, notminecraft:player_explosion
A registered damage type Horizon keeps no kind of its own for keeps its id and its tags on a mob and the dragon: a
datapack's demo:heat hurt through ApplyEffectTickEvent::hurt or entity_hurt reaches the hurt hooks as
demo:heat.
The causing and direct entities are 0 for now in these cases:
- A player's direct entity outside a projectile hit, and
magic()dealt during one (an ownerless wither skull, instant harm from a tipped arrow): vanilla's source names no entity - A player's causing entity, except for a mob that shot the projectile, the mob
/damagenames, an evoker that summoned the fangs, a slime or magma cube that touched the player, and a mob that exploded (a sulfur cube's is guessed until its blast names its source) - A mob's causing and direct entities when a player is behind a hit outside a scoped attack (for example TNT a player lit); a projectile's hit does run inside its owner's attack
- A mob's direct entity when it isn't the causing one outside a projectile hit (for example a TNT blast)
- A falling block on a mob, whose causing and direct entity is the block in vanilla
- The dragon's direct entity for a blast
- The dragon's causing player when it isn't the acting one on a LAN guest's turn
/damageon a mobbyan entity with a damage type that isn't melee: the direct entity is 0, not that entity
And /damage on a mob by one entity from another with a melee damage type gives the from entity as the direct
one, where vanilla gives the by one.
native_menu_button handles a mod container's button (AbstractContainerMenu.clickMenuButton(Player, int)) on the server, for single player and LAN guests. It exposes the player identity, button, block entity and mutable carried stack, so a widget outside the slots can take or give what the player carries.
-
native_block_entity_sync(pos), which sends the block and its update tag to clients again (Level.sendBlockUpdated): with the level's block changes, to the players who see its chunk, once per position however often it is called in a tick. A change made in a block entity's tick reaches clients at the start of the next tick, as in vanilla. Call it when what clients show changes, not for every tick's progress: a menu's data slots carry that. -
native_entity_leave_level(dimension, entity, kind), an entity leaving a server level (NeoForge'sEntityLeaveLevelEvent, vanilla'sonTrackingEnd): removed for any reason or its chunk unloading, not the entities still loaded when the server stops. It only hears. It tells item entities, experience orbs and mobs (kindis the mob type) so far; checkkind. -
Fluid tanks on a mod block's block entity, in millibuckets (a bucket is a thousand).
native_block_entity_fluidsdeclares the tanks, asnative_block_entity_containerdeclares the slots: each tank's capacity, the tanks each face reaches, and which a fill may reach and a drain may take; the tanks' contents live in the entity's saved compound underfluids, so they survive restarts, and the block's own tick reads them there. Other blocks and mods reach them, and a cauldron's, through theFLUIDScapability. -
What the mob effects of a content mod (installing and using mods) do, named after vanilla's
MobEffectmethods, or NeoForge's event where it has one. On the server, each tick a player or a mob has one,native_should_apply_effect_tick_this_ticksays whether it acts (vanilla's base answer is no) andnative_apply_effect_tickacts, and may end it, before it counts down, as inMobEffectInstance.tickServer. These two take&self: during a mob's tick the mob world borrows the game, so&mut selfwould alias that borrow, and the game doesn't hold the mobs being ticked, nor the player's effects.native_on_effect_addedhears it given to an entity that didn't have it, andnative_mob_effect_added(NeoForge'sMobEffectEvent.Added) every time it is given. A mob is itsEntity.getId; a player is None, with their state swapped in (host.players().acting_player). Vanilla's effects never reach them. The three moments around them cover every effect, vanilla's included:mob_effect_applicable(NeoForge'sMobEffectEvent.Applicable, vanilla'scanBeAffected) answers whether an effect can land — the first mod to answerAlloworDenyoverrides the vanilla tag check, and an unanswered hook falls back to it;mob_effect_expired(.Expired) hears one count down to nothing — cancelling refuses the expiry, and the effect stays, kept at no ticks and asked again next tick;mob_effect_remove(.Remove) hears one about to be removed by any path — a command, milk or a mod's removal helper — and cancelling keeps it. All three name their entity as aLivingRef, the player's own included. -
Chunks and players coming and going, on the server:
native_chunk_load(with whether the chunk is new),native_chunk_unload, andnative_player_logged_inandnative_player_logged_out, which come in pairs however a player leaves, the server stopping included. Changes made to the departing chunk innative_chunk_unloadaren't saved: as in vanilla, the chunk is saved before it unloads.One tick's streaming step (
update_world) runs in this order:- The chunks that come and go are worked out. A chunk saved while that is planned is copied for its save there.
- The arriving chunks join the level and the departing ones leave it.
- Each departing chunk that changed since its last save is copied, with its block entities and save data, and the copy is queued to save.
native_chunk_loadruns for each arriving chunk (native_chunks_loaded).native_chunk_unloadruns for each departing chunk.- The departing chunks' entities, block entities and block names are dropped.
So an edit a
native_chunk_loadlistener makes to a chunk departing in the same tick (a neighbour's block entity, say) comes after that chunk's save copy (step 3): it is not saved, and step 6 drops it. That is the difference from an ordinary load, where the edit lands in a chunk that stays and is saved with its next save, and it is the same rule asnative_chunk_unload, whose edits also come after the copy. Change a chunk you may lose from a later tick instead, after checking it is still loaded. -
Sounds the server plays to its players:
native_play_level_sound_at_positionandnative_play_level_sound_at_entity(NeoForge'sPlayLevelSoundEvent.AtPositionand.AtEntity, inServerLevel.playSeededSound). As in vanilla, a mob's own sounds (Entity.playSound) are at its position; onlyplaySound(except, sourceEntity, ...)sounds are at an entity: vanilla's sites go throughGame::play_bound_sound(the mobs' throughMobEvent::BoundSound), which queues them for the end of the tick, after the tick's other sounds; the goat horn and a mod's bound sound ask directly. Two sites keep a difference: dye on a sheep and an allay's sounds reach the player who caused them too, since Horizon's client plays them from the packet rather than predicting them. The packet stays positional (horizon-rust/horizon#9284). A mod may change the sound, its source, volume and pitch, or cancel it. Every server sound packet but/playsound's goes throughGame::send_level_sound(game/interface/level_sounds.rs), or the native-sound flush for a mod's own, so a new sound site asks the mods by calling it. The tick's sounds are asked as they go out, at the end of the tick. With no listener, the cost is onelistenedcheck per sound and nothing is allocated. -
Chunks a player watches, on the server:
native_chunk_watch(player, chunk),native_chunk_sent(player, chunk)andnative_chunk_unwatch(player, chunk)(NeoForge'sChunkWatchEvent.Watch,SentandUnWatch). Each chunk that comes into a player's view is watched, then sent; it's unwatched when it leaves the view, when the player leaves the level (a portal, a teleport, a respawn) and when they disconnect, in the level it was watched in. Vanilla watches a chunk when it queues it; Horizon picks a chunk only as it sends it, so the watch comes just before the send. The hub records them only while a mod listens and the game tells them after the hub's network step for that level, so a handler sees the state after that step; the order per player and chunk is vanilla's. The far ring's columns past the view distance aren't told, and the server stopping doesn't unwatch. -
A chunk's ticket level changing, on the server:
native_chunk_ticket_level_updated(chunk, old, new)(NeoForge'sChunkTicketLevelUpdatedEvent). The level is the chunk's loading level from its tickets (a player, a forced chunk, a portal ticket, the dragon fight's arena), onChunkLevel's scale: 31 and lower entity ticking, 32 block ticking, 33 loaded without ticking, andChunkTicketLevel::Unloadedpast it, so a non-ticking or unloaded chunk is never clamped to the ticking range. Horizon keeps no level for a chunk partway through generating, and no chunk holder to hand over. The level's tickets keep each change, in the order they were made, only while a mod listens: with no listener an update of the tickets looks at no chunk and allocates nothing, so the tick costs what it did. A level is told in its own tick, after the tickets were updated for its players. -
the renderer's hook points:
world_drawn, called once the world is drawn with its colour, depth, matrices and entity geometry, andrender_needs, where a mod asks for the entity runs and the world's depth without a shader pack. -
entities for server mods:
native_summon(dimension, kind, pos, nbt)spawns any entity from its compound as/summondoes. -
native_item_bar(stack, bar): the small barGuiGraphicsExtractor.itemDecorationsdraws under an item, wherever the game shows one.bararrives holding vanilla's choice (the damage bar, or a bundle's fullness, already applied); set it to a width0..=13and anrgbcolour, or toNoneto hide it. Asked each frame a stack is drawn. Client-side (NeoForge has no event for this; vanilla'sItem.isBarVisible/getBarWidth/getBarColor). -
Mods' own sound events in
native_play_sound: an event of another namespace thanminecraft(mymod:machine_hum) is sent to players by name, as vanilla sends a sound that isn't in its registry, and they hear it when their resources define it (a content mod'sassets/mymod/sounds.jsonand its.oggfiles).minecraft:events must still be registered. -
Nicknames for server mods:
player.set_nickname(host, Option<&str>)shows a name in place of a player's in chat (their messages,/me,/msg) and the Tab list, the real name on hover and everywhere else;host.messages().text_allowed(text)asks the server's chat filter. Obtain aPlayerReffromhost.players().server_players()or.acting_player()and retain that value, not the borrowed service. The handle is tied to its world epoch and entity identity: nickname writes refuse stale or offline players, but can reach a connected player displaced by another player's turn. A nickname already set persists across reconnects until changed or the server stops. -
Tag and fluid reads from any hook that has a
Host:host.block_in_tag(oak_log, "minecraft:logs"),host.item_stack_in_tag(&stack, tag)(ItemStack.is(TagKey)),host.state_fluid_state(state)(BlockState.getFluidState: water for a waterlogged state,Nonefor an id no state has) andhost.fluid_in_tag("minecraft:water", "minecraft:water"). The tag may be written with or withoutminecraft:, with no#. They read the world's merged tags, data packs and content mods included; an unknown tag is false and an empty stack testsminecraft:air. On a client they read the tags its server sent. The tag reads areNonebefore those or the world's tags are loaded.host.state_outline(state)(BlockState.getShapewith an empty getter at the origin) gives a state's outline boxes in the block's own space, with no random offset and no world context; empty for air,Nonefor an id no state has.host.tag_members(TagRegistry::Block, tag)(alsoItemandFluid;HolderSet.Namediteration) lists a tag's members in vanilla's order, the file's order with nested tags in place and the first occurrence kept: an unknown tag lists nothing, and it isNonewhere the tag reads are.level.fluid_state(host, pos)(BlockGetter.getFluidState) reads the fluid at a position,Nonewhere the block read is (an unloaded chunk or ayoutside the world). -
Fluid facts from any hook that has a
Host, on either side and with no world:host.fluid_is_same("minecraft:water", "minecraft:flowing_water")(Fluid.isSame: a source and its flowing form are one fluid; false when either id names no fluid),host.fluid_bucket(fluid)(Fluid.getBucket:minecraft:water_bucketorminecraft:lava_bucket) andhost.fluid_pickup_sound(fluid)(Fluid.getPickupSound:minecraft:item.bucket.fillorminecraft:item.bucket.fill_lava). Fluids are named by id, with or withoutminecraft:. The bucket and the sound areNoneforminecraft:empty, which has neither (vanilla's bucket there is air), and for an id no fluid has. -
A block part's
is_pathfindablechanges whether a path of aPathComputationType(Land,Water,Air) may go through the block's states (BlockBehaviour.isPathfindable);event.pathfindablestarts with the block's own answer. The answer depends on the state and the type alone: the game asks once per state and type when parts resolve (at world load and on every/reload) and path finding, spawning and the goals read the stored answer, so a part cannot answer from a position, a level or the time. Server-side. -
A block part's
should_check_weak_poweradjusts whether a signal query reads indirect power through that block, for its position and input direction. Block entity neighbour notices useon_neighbor_change;weak_changesopts the farther block into notices through a conductor (see the block entity neighbours concept page). -
A block part's
should_redstone_wire_connect_toadjusts whether redstone wire connects to that block for the given position and optional direction. -
A block part's
connects_tosays whether a fence, a wall, or iron bars or a glass pane of that block connects a side toward a neighbour state.event.kindnames vanilla's rule andevent.connectedstarts with its answer:ConnectionKind::FenceisFenceBlock.connectsTo(a solid face, a fence of the same kind, a gate across the arm),WallisWallBlock.connectsTo(a wall, a solid face, bars or a pane, a gate across the side; a connected side is still low or tall from the block above) andBarsisIronBarsBlock.attachsTo, for iron bars and panes, which are one block class (a solid face, bars or a pane, a wall). Set it to false to deny a connection as well as to true to make one.posandstateare the block being shaped,neighbourthe state beside it,directionthe way from the block to the neighbour (vanilla's bars rule takes none) andface_solidwhether the neighbour's face toward it is sturdy. It is asked on placement and on every shape update toward a side, on authoritative servers only, and only the block's own part answers, so vanilla blocks cost nothing. The three rules stay separate in the library; only the question a mod is asked is shared, andkindleaves room for fields one kind alone needs. Diverges from upstream: new hook.Migration (
fence_connects_to→connects_to, contract version 2): a part that implementedfence_connects_to(&self, level, event: &mut FenceConnectsToEvent)implementsconnects_to(&self, level, event: &mut ConnectsToEvent)instead. The event's fields are the same pluskind; a part that only answered for fences returns early unlessevent.kind == ConnectionKind::Fence. The answer, its timing and the fence's own rule are unchanged. -
A mod block's comparator output, as vanilla's
BlockBehaviour.hasAnalogOutputSignalandgetAnalogOutputSignal:native_has_analog_outputsays a block of yours has one, andnative_analog_outputgives it (0 to 15) for its state and position. A state change with neighbour updates tells the comparators itself; when the output changes otherwise,level.update_neighbors(host, pos, state, NeighbourUpdate::OutputSignal)makes them read it again (Level.updateNeighbourForOutputSignal).
Hooks::component_types, blocks, items and standalone_models take
&RegistrationContext before their event; Hooks::register takes it after
its Registrar. context.mods() returns a read-only LoadedMods view whose
ids() lists startup code and selected content packages in dispatch order.
context.registry("minecraft:item") lists a built-in registry's names in ID order and
context.setting(mod_id, key) reads a loaded mod's setting, so a mod can validate the keys it
declares before its Parts are finalized. Both read one view the engine captures before the first
registration hook runs: every registration step sees the same registries (as registries.json
lists them, without what mods declare during registration) and the settings on disk at that
moment, whatever the dispatch order. A read fails with a RegistrationReadError rather than
panicking: InvalidRegistryName for a name that is not a namespaced identifier,
UnknownRegistry, InvalidSettingKey for an empty key or one naming a path, UnknownMod for a
package that is not loaded, and Unavailable when the engine supplied no data. These contexts are
supplied by the engine and have no game Host, handle constructors or transactions. Declarations
still go on each event or registrar.
-
native_component_types(context, types)runs once before content loads. Append namespaced component IDs; duplicates, invalid IDs and theminecraftnamespace stop startup.native_component,native_set_componentandnative_remove_componentread, set and remove registered components, including item defaults. Values use NBT and must fit the network reader limits. -
explosions for server mods:
LevelRef::explode(host, center, radius, fire, interaction)sets one off asLevel.explodedoes, withExplosionInteraction::None,BlockorTntfor what it does to blocks. -
Dispenser behaviours are chosen at
DispenserBlock.getDispenseMethod, before any vanilla behaviour, for mod or vanilla items, on the authoritative server only (droppers are unaffected). The block the dispenser faces is asked first:native_dispense_methodgives its part the dimension, dispenser position, facing, the selected stack andstate, the block atposmoved byfacing(air included), and the part is asked only for that block. Then, unless that part answered, the item's own part is asked bynative_dispense_itemwith the same dimension, position, facing and stack. Either answer isSome((remainder, event)): aNoneremainder empties the slot,Some(stack)writes that remainder without consuming again, andeventis the level event played with the smoke (1000 click, 1001 fail, 0 nothing, asNOOP). The first answer wins: the other part is not asked and vanilla's behaviour doesn't run.host.dispense_default(dimension, pos, facing, stack)drops one item normally, including items with a special vanilla behaviour: answer its remainder with 1000. Leave the answerNoneto let vanilla decide, as before. -
Image tooltip components:
native_tooltip_component(stack, size)answers finite positive width/height in GUI pixels, andnative_tooltip_component_draw(stack, batch, origin, size)draws that component withnative_sprite,native_fillandnative_text. The stack supplies its data. Space and background are reserved after the text and any vanilla bundle component; the component width participates in tooltip positioning. Neither hook changes ordinary tooltips without an answer. Client-side. -
ClientHost::modifiers()returns aClientModifierssnapshot of the window's shift, control, alt and super keys, using the same GLFW bits as the input hooks. It is available outside a world and clears on focus loss. Reading it copies the current modifier state; it does not allocate or dispatch hooks. -
Hooks::render_tooltip_gather_components(NeoForge'sRenderTooltipEvent.GatherComponents, client-side, cancellable): an item's tooltip as one list ofTooltipElements, aTextline with its colour or anImage(aTooltipImage: the stack whose item'stooltip_component_drawpart draws it, and its size in GUI pixels), afteritem_tooltipand before anything is wrapped or measured. Add, change, reorder or remove elements.max_widthstarts at -1, no limit, which leaves the lines as vanilla draws them; a mod that sets it wraps every line wider than that. Cancel and nothing is drawn. An image with a non-finite or non-positive size is dropped. -
Hooks::render_tooltip_pre(NeoForge'sRenderTooltipEvent.Pre) is asked for each tooltip about to be placed and drawn, afterrender_tooltip_gather_components: item tooltips and text tooltips (a button's, a book's, the creative tab names), not the selected-item name a bundle draws beside its grid.placeis the point the tooltip hangs from (the cursor, or the point its screen gives), before the positioner adds its offset and keeps the box on screen; set itsxandyto move it.screen_widthandscreen_heightare the screen's size.elementsare the tooltip's lines then its images, read-only (change them inrender_tooltip_gather_components), andstackis the item it describes,Nonefor a text tooltip. Setcanceland nothing is drawn and norender_tooltip_textureis asked. It runs each frame a tooltip shows, only while a mod listens. Diverges from upstream: new hook. -
Hooks::component_hover(vanilla'sGuiGraphicsExtractor.componentHoverEffect; NeoForge has no event for it) is asked for the hover of the text component under the cursor in the chat, a book or a dialog, before the game draws it.hoveris the typed hover action (TextHover: its text, an item or an entity),xandythe cursor. Setcanceland the game draws nothing for it, and a mod can draw its own withdraw. Left alone, a text hover is split at half the screen's width (at least 200), an item hover draws its stack's tooltip, which asksitem_tooltipand therender_tooltip_*hooks as any item tooltip does, and an entity hover shows its name, type and UUID while advanced tooltips are on. It runs each frame a hovered component shows, only while a mod listens. Diverges from upstream: new hook. -
advancement triggers of a mod's own:
player.trigger_criterion(host, trigger, test)fires a trigger an advancement of a content mod or a data pack names (mymod:pipe_connected) for one player, as a NeoForge mod'sSimpleCriterionTrigger.trigger. -
Where a mod block can stay, on an authoritative server.
native_can_survive(dimension, pos, state, survives)answersBlockState.canSurvivebefore the block does: as it is placed (BlockItem.canPlace:Some(false)refuses the spot), from the shape update of a block whose template checks it (a plant, a torch, a sign:Some(false)breaks it with its drops when a neighbour changes), and wherever else vanilla asks.statemay not be in the world yet, so decide fromstate.native_may_place_on(dimension, state, ground, ground_pos, may)answersVegetationBlock.mayPlaceOnfor a mod plant (a block whose template is a vanilla plant, such as a crop): the blocks it can stand on, which its template'scanSurviveasks, so it is refused elsewhere and pops off when its ground changes. Answer only for your own blocks and leave the answer empty otherwise; the level is read-only. Both run on every shape update of such a block: keep them short. -
Drawing mod block entities on the client.
native_block_entity_submit(pos, block, state, entity, draw)runs once a frame for each mod block entity in view, as vanilla'sBlockEntityRenderer.submitdoes (within its view distance, 64 blocks unlessnative_block_entity_view_distanceanswers otherwise, asked once per mod block and kept, again after a resource reload or a world change; Diverges from upstream: new hook; in the frustum, its chunk faded in), with the copy the server sent (native_block_entity_update_tag). It draws into the entity batch underdraw's pose stack (push,pop,translate,rotate_about,scale, in blocks from the block's corner, as vanilla'sPoseStack):native_draw_quad(four corners and a sprite's UVs),native_draw_cube(a model element's box, its faces baked as a model's are, each aCubeFace::new(sprite),.with_uv(uv)for UVs of its own),native_draw_block(a block state's model, assubmitBlockModel: a moving part is often a block of the mod's own) andnative_draw_text(BlockEntityDrawContext::text(text, at, rgb): a line of the game's font, as a sign's, in font pixels with y down asFont.drawInBatchlays it out under the pose, opaque0xRRGGBB, in the frame's light;set_lightto full bright for a glowing display such as a nixie tube;text_widthmeasures it to centre it). Sprites are the atlas's:<ns>:block/...andblock/..., and a content mod'sassets/<ns>/textures/entity/as<ns>:entity/.... Culling uses the boundsnative_block_entity_render_bounding_boxgives (NeoForge'sgetRenderBoundingBox): the block with a block of margin unless the mod widens them, or never culled when a bound is infinite. Both run every frame for every one in view: keep them cheap. -
Bone meal on a mod block, the
BonemealableBlockthree questions a block nothing in Horizon knows one for answers itself:native_is_valid_bonemeal_target(state, valid)says whether the block takes bone meal (Some(false)refuses it even where its template takes it,Noneleaves the template's; asked on both sides, as vanilla asks it, so a client predicts the use);native_is_bonemeal_success(state, success)isisBonemealSuccess, drawn fromnative_level_random()to keep vanilla's order; andnative_perform_bonemeal(state)is the growth itself, withnative_set_block/native_set_block_property. The meal is used up and the growth particles and sound follow as vanilla's do, whether or not the roll grows the block, from a player's bone meal, a dispenser's and, on a crop, a villager's. A block that answers nothing keeps theBonemealableBlockits template gives it. Authoritative servers only, except the first question. -
Bone meal on any block:
Hooks::bonemeal(NeoForge'sBonemealEvent) hears everyBoneMealItem.growCrop, before the block is asked anything: a player's use on both sides, a dispenser's and a villager's. It names the player (none for a dispenser or a villager), the block, thesource, the stack (what the mod changes stays) andvalid_bonemeal_target, the block's own answer. Cancel it and the growth, the block's bone meal Parts and the meal's use are skipped:growCropanswerssuccessful(setSuccessful), false unless the mod sets it, so a player's use still tries the water plant beside the clicked face. A mod growing only its own blocks answers the three questions above instead. -
Mod-block entity contacts:
native_block_step_onreceives the server dimension, entity id/type, block state and position. Landing is a block Part method, not a global hook (block_fall_onis removed; it diverges from upstream):BlockPart::fall_on(Block.fallOn) receives the fall distance and mutable calculated damage; zero prevents fall hurt and its generic fall sounds. Creative and fall-immune entities notify with zero and retain immunity. Invalid non-finite damage changes are ignored; negative damage is clamped to zero.BlockPart::bounce_on(Block.bounceOn) fires only when the entity bounced, just before vanilla sets its movement, and may change the movement the bounce leaves. Both run synchronously inside the entity's move on the level random host (reads blocks, draws the level's random in vanilla's order, attachments; no writes); players and mobs fire them, and dropped items firefall_on(kindminecraft:item, zero damage, read and act on the item throughItemEntityRef; an item'sbounce_onis not fired yet). An item's fall distance grows by its moves down out of water and resets in water and on landing, asEntity.checkFallDamagehas it; with no Part on the block it lands on, the landing costs onefall_listenslookup.fall_onasks for writes withevent.request(LandingWrite::SetBlock(state))orLandingWrite::DestroyBlock { drop }(farmland to dirt after its random draw, a turtle egg breaking): they apply at the batch end through the same drain asentity_inside, in the order asked, if the block is still the state the entity landed on. -
Block.entityInsideisBlockPart::entity_inside(host, event)(EntityInsideEvent { state, pos, entity, precise };preciseisEntity.checkInsideBlocks' flag), the Part form of whatblock_entity_insidewas; there is no global hook (NeoForge has no bus event for it). It fires for mobs, projectiles, dropped items and players on the server, with a&mut dyn ServerHost, once per entity and block per move, and only for a block state a Part listens for; with no listening Part nothing is recorded. The contacts are noted during the sweep and delivered after the entity-kind batch has ticked (mobs, then projectiles, then items), in the order they happened, so a Part's own writes and random draws come after the batch's later entities, not mid-sweep. A contact whose entity is gone (an item that merged later in the batch) or whose block changed is dropped. Players fire it too, after their turn. TheInsideBlockEffectApplier, and the block'sgetEntityInsideCollisionShape/insideBlockshape that decides "inside", are not exposed (tracked on #44). -
A consumable's effect of a mod's own kind. The item's
on_consume_effectsentry of a type in a namespace of its own reachesnative_consume_effect(kind, data)when the item is consumed, on the server thread, for the acting player. The kind and the payload converted once to a retainedTagat definition load are there; the game's own effects, sounds, teleports and drops are open to the hook. An unclaimed kind does nothing. The hook sees the item's own consumable component, not a patch on the stack; a bare vanilla name and an unknownminecraft:kind are the loader's, not the hook's. -
loot table hooks, for changing loot a mod didn't make.
native_loot_table_load(context, id, table), inhorizon-world'sloot.rs, gets each loot table's JSON as it loads, after the data packs' (NeoForge'sLootTableLoadEvent, Fabric'sLootTableEvents.MODIFY): add pools or entries, change or replace it, and chests, block and mob drops, fishing and the rest then roll the changed table. It runs on the server for each data load and reload, not client preloads.native_loot_drops(context, drops)gets the items a broken block or a dead mob is about to drop, to add, change or take out (global loot modifiers,MODIFY_DROPS), withLootDrops: the table rolled, the dimension, the origin, the block or the mob, the player who broke or killed it, the tool and an explosion's radius. It hears blocks a player, an explosion or a mob breaks, not other breaks such as leaves decaying. -
native_state_for_placement(state, context, fluid, answer): the state a block is about to be placed in (Block.getStateForPlacement(BlockPlaceContext)),statethe block's own default state. Answer only for your own blocks:answerset toSome(Some(state))places that state (a state of that block, as vanilla sets properties on its owndefaultBlockState; another block's is ignored),Some(None)refuses the placement (vanilla'snull: the block goes nowhere and the stack is kept), and left empty the block's own placement runs.contextis vanilla'sBlockPlaceContext: the clicked face, the placer's rotation, the clicked position, whether the clicked block is being replaced and the item in hand.fluidis the fluid in the position the block would go in, which a waterlogged block reads. It is asked for every placer that goes through vanilla's placement (a player'sBlockItem.place, a dispenser'sDirectionalPlaceContext), and the answer still takes its survival and entity obstruction checks.native_state_with_property(state, key, value)builds the state fromstateand one of its properties, its own declared ones included. Authoritative servers only. -
A
minecraft:post_attackenchantment effect of a mod's own kind, on a mob a player's weapon hit:native_enchant_effect(see A mod's own enchantment effect kinds). -
native_screen_render_post, the point ofScreenEvent.Render.Post: a native mod draws over the open screen after it and its tooltips, given its id, the server-opened menu's vanilla type (menu) and title, its container panel, its slot count, the mouse in GUI pixels and the partial tick (ScreenView). Every container, station and mod window draws on the inventory screen and so reportsinventory: tell them apart bymenuandtitle(None on a window the client opened) and byslots. It does not fire on the title screen, nor for the prompts, books and sign editors drawn over gameplay after it. -
native_screen_exclusion()answers the rectangles ([x, y, width, height], GUI pixels) the open screen keeps clear for the things drawn beside its window: the recipe book's panel, the creative tab rows above and below the window, and the active effects right of the inventory, then every rectangle a mod added innative_screen_exclusion_areas(screen, areas)(a side panel, a button column; a mod that draws over a screen reports where). Compute it once per screen layout and cache it, not per frame: the rectangles move only when the window, the recipe book or the effects change. A mod that lays out panels of its own, as a recipe viewer does, shrinks its rows and columns around them. Empty and non-finite rectangles are dropped. Empty with no screen up. Vanilla has no counterpart; NeoForge recipe viewers take the same from their own API (EMI'sEmiExclusionArea, JEI'sIGuiContainerHandler.getGuiExtraAreas). Client-side. -
native_item_burn_time(item)answers the item's cooking-fuel burn time in ticks (FuelValues.burnDuration: itscooking_fuelcomponent, the data pack's context int provider resolved), None where it is no fuel.native_item_compost_chance(item)answers the chance the item raises a composter's level (itscompostablecomponent's layers provider resolved; thecompostablesdata map), None where it is not compostable. A mod cannot resolve either from the item's default component, which holds only the provider's name. Client-side, API next. -
native_screen_hovered_item()answers the id of the item in the slot under the pointer on the open container screen (AbstractContainerScreen.getHoveredSlot()'s stack), None over no slot, an empty one, or with no window. A recipe viewer reads it for its look-up keys. Client-side, API next. -
menu.slot_boxes(host)(menu_slot_boxes) answers the open inventory screen's slot rectangles ([x, y, width, height], GUI pixels) in menu slot order:AbstractContainerScreen.leftPos/topPosplus eachSlot.x/Slot.y, 16 by 16, so a mod that highlights or searches slots innative_screen_render_postdraws where vanilla's own slot highlight does.Noneentries are slots the screen does not show; the whole answer isNonefor a stale menu, another screen, or an inventory covered by the recipe book. Client-side. -
native_modify_default_components(context, item, components), inhorizon-world'sitem_defaults.rs: an item's default components initems.json's form, for any item, vanilla's included (NeoForge'sModifyDefaultComponentsEvent). Insert or replace a component to set it ("minecraft:max_stack_size": 16), remove it to take it away. The item table, loot, enchanting and item models all read the changed defaults, and stacks are saved and sent against them as against vanilla's. Aminecraft:max_stack_sizeabove vanilla's 99 is kept, up toi32::MAX. Asked for every item each time the game reads the defaults: answer the same each time. Client and server each ask their own mods, so both need the mod. -
The open screen hears of a mouse button going down (
native_screen_mouse_button_pressed_pre) or up (native_screen_mouse_button_released_pre) before it acts, as vanilla'sScreenEvent.MouseButtonPressed.Preand.MouseButtonReleased.Pre: the screen, the GLFW button (0 left, 1 right, 2 middle, 3 back, 4 forward; a button past those is the platform's own number, which differs between systems) and GLFW's modifier bits (shift 1, control 2, alt 4, super 8), with the pointer inscreen.mouse. A cancelled press goes no further (no slot, screen button, drag or hotbar key); cancel a release only when its press was cancelled, or a drag it ended stays held. They fire on the window only, never on the title screen itself (Options and the pack list opened from it do fire), the connect and loading screens, the prompts, books and sign editors drawn over gameplay, or while a world loads or saves, and never for a gamepad or keyboard-focus click. -
falling blocks for server mods:
native_spawn_falling_block(dimension, state, pos, velocity, falling)throws a block that flies, lands and places itself as sand does, for debris and collapsing structures. -
Camera shake for client mods:
native_camera_shake(partial, shake)asks each frame for turns in degrees and an offset in blocks, which add up across mods and multiply into the view after its bobbing. Horizon scales the sum by Distortion Effects and bounds it to 10 degrees and half a block on each axis. -
Client sounds:
ClientHost::play_local_sound(sound, after)plays aSoundPlay, vanilla's event or the mod's own from itsassets/<id>/sounds.json, for this player only: an interface sound (SoundPlay::ui) or one at a place (SoundPlay::at), now or up to a minute later, so a client mod can click its own buttons or time a blast's sound to its shock wave. ItsSoundRefanswers whether it plays, and stops or fades it. -
Values for a mod's own shader pack:
native_shader_params(partial, params)asks each frame for eightvec4s that every stage of a Horizon shader pack reads asframe.mods, so a mod can tell the pack it bundles where and when something happened (an explosion's centre and age for a cloud drawn in a post pass). -
Menus of content mods' own menu types (vanilla's
MenuType): a mod container'smenumay name a menu type its content mod declares indata/<id>/horizon/menu/with as many slots (making a content mod). It opens when the block is used, its data slots are the block entity'sdata, and a Horizon client shows it through the mod'snative_screen. -
native_block_highlight(pos, state, partial, canceled)runs before building the eligible targeted block's selection outline, after hidden-HUD and game-mode restrictions. Setcanceledto suppress only that outline; default behavior is unchanged otherwise. Position is block coordinates and state is the current block state ID. No entity outlines are affected. -
native_render_hand(render, draw, canceled)before each visible first-person hand, including maps, the empty main-hand arm and the empty off hand, where vanilla draws nothing.rendercarries the player UUID and entity id, hand, displayed stack, partial tick, interpolated pitch, light, swing and inverse equip height including swap animation scale. Adjust its translation (blocks), rotation (pitch, yaw, roll in degrees) and scale; the pose is applied in that order before the ordinary hand and item poses. Non-finite or out-of-range components are ignored individually: offsets must be within -16..=16 blocks, angles within -3600..=3600 degrees and scale within 0.01..=100. Setcanceledto skip default geometry.drawdraws in the hand, as the submit hooks' draws do: its pose stack starts in the hands' view space (blocks, +x right, +y up, -z into the screen) under the hand sway, beforerender's pose and vanilla's per-item transforms, and both hands share it (render.handsays which one is asked). What a mod drew joins the hand's own vertices ahead of vanilla's, in a run of its own with no rendered item id, as the arm's: it gets the hand's field of view and the view bob, a shader pack's hand pass draws it,hand_drawnsees it, and it stays when the hand is cancelled. So cancel and draw to replace a hand, or draw without cancelling to add to it; it covers a hand that holds an item, whererender_arm(vanilla's arm only) never fires, and the empty off hand, for a two-handed pose's second arm. Unlike NeoForge'sRenderHandEvent, the pose stack isdraw's own: turning it moves what the mod draws,render's pose moves the default geometry, and player render state is not exposed.
native_screen_opening(current, new, outcome) (ScreenEvent.Opening), native_screen_closing(screen) (ScreenEvent.Closing) and native_screen_init_post(screen) (ScreenEvent.Init.Post) take a ScreenView. Every change of the game's screen (all of the game's screen assignments) goes through Game::set_screen, which mirrors Gui.setScreen: when a different screen would open, screen_opening fires first with the screen that is open (current) and the one about to open (new; the id playing is vanilla's null, no screen), and answers ScreenOpening::Cancel (the current screen stays) or ScreenOpening::ModScreen (the answering mod's own screen opens instead, if it can open there; the replacement fires nothing again); the first mod to answer wins, and the dispatch records its id, so a mod can open only its own screen. Then screen_closing fires for the screen being left (never for playing), and the new screen opens. The same screen again fires nothing, and a game where no mod listens to screen_opening or screen_closing assigns the screen with no event. screen_init_post is still observed once per frame (an open or a resize). The screens of the game's Screen list (inventory, containers, pause, options, chat, creative, stats, advancements, mod screens, dead, win…) announce themselves in set_screen, before they change, and honour Cancel and ModScreen. The overlays drawn over gameplay (sign editors, book edit/sign/view and lectern screens, dialogs, and the command, link, code-of-conduct and server-pack prompts) open through Game::open_screen_overlay, which fires screen_opening before the overlay is set and honours Cancel and ModScreen in the same way (a canceled server-pack prompt leaves its packs unanswered, a canceled code of conduct leaves the server waiting, as in NeoForge), then screen_closing for an overlay it replaces. The title's own screens (world_select, world_create, world_edit, multiplayer, onboarding…) open in the same way: Title::set_mode asks for the screen while a mod listens to screen_opening or screen_closing, and the game fires screen_opening (from the title screen shown) and screen_closing for the one left before it applies it, after the window event or control command that asked; Cancel keeps the screen shown, and ModScreen opens the screen asked for, since a mod's own screen can't open over the title. The multiplayer list's own steps (add_server, edit_server, direct_connect, server_delete, friends_join, and back to multiplayer) are title screens too and go the same way. The title appearing as a whole (at startup, when a world closes, after a disconnect or a failed join) and going (when a world opens) goes through Game::show_title and Game::hide_title: screen_opening (from no screen to the title's screen, or back) and screen_closing for what it leaves fire before the change, and screen_init_post follows an appearance; a Cancel doesn't keep the title shut when there is no world to show instead, nor keep it open over a world that opens. Still announced by the per-frame observer, through the same function and in the same order but after the change, so a mod's answer can't keep it shut: an overlay's closing. Overlay views use the full window as their rectangle and have no menu or title; anvil rename remains part of its inventory screen. Dialog states share the dialog id. A menu swapped inside the open inventory screen is not a change of screen.
-
native_client_resources_reloaded()fires on the render thread after successful client resource-pack application: the atlas, models, renderer and client caches already use the new resources. It covers selection/F3+T and server-pack reloads. Failed staging or application and initial startup do not fire it. This is a completion notification, not a reload-listener registration or staging API. -
native_texture_atlas_stitched(atlas)(NeoForge'sTextureAtlasStitchedEvent) fires on the render thread once per atlas the resources publish,TextureAtlas::Blocksand thenTextureAtlas::Gui: on the first load beforeclient_setup, and after every successful reload beforeclient_resources_reloaded. A failed reload fires nothing, and the old sprites stay right. Horizon packs vanilla's many atlases into these two: the block sprite arrays (texture_spritesanswers aSpriteRef) and the GUI atlas of GUI sprites, particles and flat item and block textures (gui_spriteanswers anAtlasRegion). Look up the sprites a mod draws here, once, and keep them until that atlas is stitched again; never by name every frame.TextureAtlasis#[non_exhaustive]. A dedicated server never fires it. -
client_stoppedfires after the client shutdown finishes.game_shutting_downfires after that on clients and after the dedicated server stops, with a side-typed host. Both fire once on orderly process exit; neither fires for a splitscreen guest. There is no separateclient_started:client_setupcovers that moment. -
native_client_stopping()(Fabric'sClientLifecycleEvents.CLIENT_STOPPING; NeoForge has no event for it) fires once per game process, on the render thread, when the game shuts down: after the world the player was in is left and saved, with the settings and resources still held, so a listener can read them through theClientHostand save its own client state. Closing the window, a quit shortcut and the stop signals reach it; a crash does not. A dedicated server and a splitscreen guest never fire it, as they never fireclient_setup. Listeners are pure functions of theClientHostreads and the mod's own state. Tick-time effect: none, it fires once at exit. Diverges from upstream: new hook. -
native_client_setup()(NeoForge'sFMLClientSetupEvent) fires once per game process on the render thread, after the first client load has applied and before the title screen's first frame, with theClientHost. It is the momentclient_resources_reloadedskips: that one never runs at initial startup and runs after every later reload, while this one runs once and never again. Returning to the title screen and opening another world, or adding a splitscreen player, is not a new start. NeoForge'sDeferredWorkQueuehas no counterpart (the listener already runs on the thread that would drain it; work a mod wants in parallel is its own). A dedicated server has no client and never fires it;FMLDedicatedServerSetupEventisserver_about_to_start. A failing listener is handled like any other listener of a hook. Listeners are pure functions of theClientHostreads and the mod's own state. -
native_vanilla_game_event(dimension, event, pos, cause, affected_state, cancel)(NeoForge'sVanillaGameEvent) fires on the server for every vanilla game event before the sculk sensors, wardens and allays hear it; canceling keeps them from hearing it.causeis the network id of the entity that caused it (GameEvent.Context.sourceEntity, None for none) andaffected_stateis the block state the event touched (GameEvent.Context.affectedState): the block a player broke or placed, the block a mob stepped on, and None where vanilla passes none (Context.of(entity)). Contract version2: version1had noaffected_state. With no listener the cost is onelistenedbranch per event. Diverges from upstream. -
native_get_arm_pose(entity_id, hand, stack, humanoid, pose)(NeoForge'sIClientItemExtensions.getArmPose) sets the pose of the arm that holdsstackinhand, as a humanoid model draws it: the window's own player, remote players and humanoid mobs.humanoidis a borrowedHumanoidRenderStateexposing both held stacks, the main arm, use hand, use ticks, use animation and the selected main/off-hand poses before listeners answer (including item declarations). It replaces what the item would give, whatever the use animation, a loaded crossbow or anarm_posedeclared on the item say (a content mod's data key, see the content-mods how-to, is the answer when no mod listens or none sets one). Return one of vanilla'sArmPosevalues (empty,item,block,bow_and_arrow,crossbow_hold,crossbow_charge,throw_trident,toot_horn,spyglass,spear,brush); the first mod to answer wins. It is asked once a tick for each humanoid model and non-empty hand, only while a mod listens, and a two-handed pose still moves the other hand as vanilla's does. Custom poses are registered throughRegistrar::register_arm_poseand returned asArmPose::Custom;arm_pose_transformwrites their API-ownedArmAnglesin radians. Unknown slots and non-finite angle components are ignored. Mob-specific animations run after the humanoid base pose, as in vanilla. API stack snapshots are converted only with subscribers; there is no allocation when no mod listens. Client-side, with theClientHost. Diverges from upstream: new hook. -
native_item_decorations(stack, x, y, batch)appends custom GUI drawing after the default bar, cooldown shade and count have been painted for an item icon. The stack is the displayed stack, x/y are its icon's top-left in GUI units, the units the batch takes (no item-local translation remains active). Existing GUI helpers can draw badges, text and overlays. The shared path covers the hotbar, carried stacks, menu slots, recipe book and item tooltip icons, and the item icons of the advancement, beacon, stats and trading screens.native_item_barstill controls the bar separately; this hook does not suppress vanilla decorations. -
Using an item over time, as vanilla's
Item.getUseDuration,getUseAnimationandfinishUsingItemand NeoForge'sLivingEntityUseItemEvent.Startand.Finish.native_use_durationanswers a stack's use time: a mod's own item with no use of vanilla's that answers above 0 is used over time on a right click, until it is let go or the time runs out, on single player and LAN alike.native_use_animationchanges the item's vanilla use animation, including specialised item poses, each first-person frame and each local or remote player model tick; a spear override without kinetic data is refused.native_can_eatdecides whether a food can be eaten now.native_living_entity_use_item_startmay change a use's duration or refuse it, on the server and where the window's client predicts the use, and on the servernative_living_entity_use_item_finishmay replace what the hand holds once the time ran out (consumables, potions and mods' items). Both take the using entity, None for a player, so mobs can fire them later, and the hand in use. Change the world only where this game runs the world, not on a server's client. -
Items and block state properties for drawing mod block entities: in
native_block_entity_submit,native_draw_item(draw, stack, display, owner_yaw, seed)draws an item stack underdraw's pose as a campfire's or a shelf's (ItemModelResolver.updateForTopItemandItemStackRenderState.submit, in the model'sdisplaytransform; withowner_yaw, the block entity is the item's owner, as a shelf is, so a compass on it points from there). The client's copy of a block entity has no item slots: send the stacks you draw innative_block_entity_update_tagand read them fromentity.nbt.BlockState::property(metadata, "facing")reads an owned state's property without a world lookup. Host callbacks supplyhost.block_states()as metadata; tint workers receive their explicit immutable metadata resource and never need a game host. -
The world's clock for client mods:
native_clock()is the shown world's game time and time of day in ticks (i64; the day time counts on past 24000), none on the title screen. -
The open screen hears of the wheel (
native_screen_mouse_scrolled_pre) before it scrolls, as vanilla'sScreenEvent.MouseScrolled.Pre: the screen, the scroll's x and y as the game's scroll handler scales them (a line times the wheel sensitivity: with Discrete Scrolling on, a notch is ±1, its sign only; a touchpad's pixels are divided by 30), GLFW's modifier bits (shift 1, control 2, alt 4, super 8) and the pointer inscreen.mouse. A cancelled turn scrolls nothing: no bundle under the pointer, no list, slider or scrollbar, and no hotbar slot. It fires for the window's wheel (horizon-ctl'sscroll-atincluded), never on the title screen itself (Options and the pack list opened from it do fire), the connect and loading screens, the prompts, books and sign editors drawn over gameplay, or while a world loads or saves, and never for a wheel a gamepad or the keyboard's focus sends. -
Keeping a mod block entity's geometry:
draw.keep()innative_block_entity_submitkeeps what that call drew, and the block is drawn the same again without callingnative_block_entity_submitornative_block_entity_render_bounding_boxuntil its state, its block entity (what the server sent), its light or the resource packs change, so idle machines cost no call into the mod each frame. Keep only geometry that reads none ofpartial,game_time, the blocks around it or other state of yours: kept geometry stands still (an enchanted item's glint, a clock or compass drawn withnative_draw_item). -
Any command a player types:
native_player_command(player, name, command, cancel, replies)is heard before the command is looked at (NeoForge'sCommandEvent), without its slash, for vanilla's commands, the mods' own and commands the player may not run, once for each command a player sends, on a server. Also each line of a quiet command batch. A mod setscanceledto stop it: nothing runs and nothing is answered but the lines it pushes toreplies, which reach the player as system chat. -
Redstone signals as a block reads them, on a level handle (
LevelRef) on a server, through the game's ownSignalGetter, mods'block_signalandshould_check_weak_powerparts included:level.signal(host, pos, direction)(getSignal, how strongly the block atpospowers its neighbour on thedirectionside, conducted power included),level.direct_signal(host, pos, direction)(getDirectSignal, the strong power alone),level.direct_signal_to(host, pos)(getDirectSignalTo),level.best_neighbor_signal(host, pos)(getBestNeighborSignal, the strongest power reachingpos, as a dispenser or a piston reads it) andlevel.has_neighbor_signal(host, pos)(hasNeighborSignal, stopping at the first powered side). Each costs what the game's own call does. They take&dyn Hostand read the live signal state without a transaction or mutable level borrow. Block signal and weak-power Part callbacks receive the restrictedLevelHost, so they cannot recursively invoke these signal helpers, just as on the write path. They read the level in place: None (or false) for another level, a stale handle or a client. -
level.weather(host)(rain and thunder levels, 0 in the Nether and the End),level.day_time(host)(the time of day),level.height(host)(min y and height) andnative_max_stack_size(item), withlevel.brightness(host, layer, pos)for light andlevel.is_raining_at(host, pos)for rain at a position: what a mod's solar panel, wind turbine, quarry or own container needs without reaching into Horizon's internals. -
Giving a whole stack (damage, name, enchantments kept) to any player from a server hook:
insert_anyinto the player'sENTITY_ITEMShandler places it asInventory.adddoes, and answers how many went. -
Registered mod commands from every source use
native_run_mod_command(source, command, ran). Players retain their swapped-in state; the console has no acting player. Functions are silent, and block/cart feedback follows track-output. Unhandled invocations fail as unknown commands. -
Player-use drops to
native_loot_drops: shears on a sheep, a mooshroom, a snow golem or a bogged, a brush on an armadillo or a suspicious block, carving a pumpkin, picking sweet berries or cave vines, and a player taking an item frame or its item pass through it, withLootDrops::usedset and the player named. -
Server experience:
native_experience_orbs(dimension, min, max)reads the experience orbs in a box;PlayerRef::give_experience_pointsgives points as an orb they touch does (Mending first), andPlayerRef::give_levelsgives or, for a negative amount, takes levels. -
Server player death location:
PlayerRef::last_death_location(host)reads where the player last died as aGlobalPos, the dimension's key and the block (Player.getLastDeathLocation, saved asLastDeathLocation, which the recovery compass follows). The dimension is data: a death in a dimension that is not open still reads, andhost.level(&pos.dimension().to_string())gives the level when it is.GlobalPos::newtakes anIdentifieranddimension()returns&Identifier, so dimension keys are validated before they enter the typed API. Players resolve as in the otherPlayerRefreads, so the acting player works too. It isNonefor a player who never died, a stale handle, and a player who is not online or whose record is swapped out.set_last_death_location(host, location)stores a location or, forNone, forgets it (Player.setLastDeathLocation); the client hears of it with the player's next respawn, as in vanilla. It returns false in the same cases and for a dead player (so insideplayer_diedthe read works and the write does not). -
Server player rules:
PlayerRef::may_use_item_at(host, pos, face, stack)isPlayer.mayUseItemAt: a player who may build always may, anyone else only with a stack whosecan_place_onmatches the blockposlies against (the check the game's own item use asks),Nonefor a player in another level than the one in place.can_harm(host, other)isServerPlayer.canHarmPlayer: thepvprule and the two teams (attacks, arrows and tridents ask the same).respawn_config(host)reads the respawn point asSome(Some(PlayerRespawnConfig { position, yaw, forced })),Some(None)for a player with none (the world spawn), andset_respawn_config(host, config)is/spawnpoint's write,Noneforgetting it: the yaw wraps,player_set_spawnmay cancel (false), and a dead player, a dimension the game doesn't have and a yaw that is not finite are refused.text_filtering(host)is the client'sClientInformation.textFilteringEnabled(false for the player running the server); it is the client's own option, so there is no write. The sleep reads aresleep_ignored(host)(whatset_sleep_ignoredleft),counts_for_sleep(host)(the night's count: not a spectator, not a fake, not ignored while out of bed) andsleep_timer(host)(Player.sleepCounter: 100 once asleep long enough, 101 to 109 on the way up);set_sleep_timer(host, ticks)writes 0 to 109 through the sleep writes' guard (no fake player, no guest in another level). Writes take aServerHost. -
Lead drops to
native_loot_drops: untying a lead or cutting it with shears (used), and the lead a mob a player killed while leashed drops on its next tick, naming the killer. -
Native mod particle types to
native_send_particles:ParticleOptions::simple(spark_key)with a load-resolvedParticleKeyformymod:sparksends a particle of the mod's own type, drawn by Horizon clients from the sprite set in the mod'sassets/<namespace>/particles/<path>.json("textures": ["mymod:spark/1", ...], resolved asparticle/<texture>in the atlas). The name must be namespaced; a client without that file draws nothing for it, vanilla clients (whose brand isn't Horizon's) aren't sent it, and the call still returns true. The type behaves as vanilla'sSimpleParticleTypewith a sprite set: a plain billboard, no tint, 20 to 40 ticks, its motion the game's default spread (the speed sets its direction, not how fast it goes).
A mod particle may opt into declarative behaviour in that same particle JSON (no new hook API):
{
"textures": ["mymod:spark"],
"horizon:behaviour": {
"velocity": [0.0, 0.15, 0.0],
"velocity_jitter": [0.02, 0.0, 0.02],
"animation": "loop",
"frame_ticks": 3,
"gravity": 0.5,
"friction": 0.96,
"lifetime": 40,
"size": [0.1, 0.0],
"alpha": [1.0, 0.0]
}
}
animation chooses how the particle walks through its sprites: age (first to last over its
lifetime), loop (cycles every frame_ticks ticks, 1..200, only valid with loop), random (one random
sprite, kept) or first (the first sprite, kept). Without animation the particle keeps the sprite it was
spawned with.
Every field is optional. Omit horizon:behaviour (or use {}) to keep the existing behaviour,
including its random draws, unchanged. velocity replaces the initial randomized velocity with
an exact vector in blocks/tick, plus the velocity the spawn call passed; omit it to retain the spawn's default spread. velocity_jitter adds a random
offset per axis, uniform in -j..=j (0..16 each, drawn from the particle's seeded generator, so the same
seed gives the same particle); declaring it alone starts from zero plus the spawn velocity. Each component must
be finite and within -16..16. gravity is the vanilla multiplier: each tick subtracts
0.04 * gravity from vertical velocity before moving (negative rises; range -100..100).
After movement, friction multiplies all velocity components (0..1); normal block collisions
and the vanilla on-ground horizontal multiplier of 0.7 still apply. Omitted gravity remains 0,
omitted friction remains the engine default (0.98), and omitted lifetime remains 20..39 ticks.
lifetime is an integer from 1 to 12000; removal uses the existing base tick: it moves that many
times and is removed on the following tick.
size and alpha are [start, end] linear curves over age / lifetime, clamped to their
endpoints. Size is billboard half-width/half-height in blocks (0..16), not a multiplier;
alpha is opacity (0..1). The start value applies immediately at spawn, then the curves update
once per tick, reaching the end after lifetime motion ticks. Omitted size keeps the random
spawn size; omitted alpha stays 1. Fading particles use the blended particle layer.
Malformed declarations, unknown behaviour fields, non-finite or out-of-range numbers reject the
whole behaviour object with a log warning, falling back to the existing particle behaviour;
the sprite set still loads. Vanilla particle JSONs are unaffected. Resource reloads apply new
declarations to newly spawned particles; live particles keep their spawn-time values.
The engine copies fixed-size numeric data at spawn and performs no new allocations per particle
tick. Declarations support linear size/opacity curves and constant gravity/friction; for per-particle
code, register a provider with register_particle_providers ("Particle providers"). A provider replaces the
declared motion (its tick moves the particle) but the declared spawn values still seed it.
-
the open screen hears of a key press (
native_screen_key_pressed_pre) and of each character it types (native_screen_char_typed_pre), as vanilla'sScreenEvent.KeyPressed.Preand.CharacterTyped.Pre: the screen, vanilla's key name (key.keyboard.e; a key with no name fires nothing), whether the press is an auto-repeat, the character, GLFW's modifier bits (shift 1, control 2, alt 4, super 8), andscreen.mousefor the pointer. A cancelled press goes no further — no focus move, no Escape-close, no inventory or hotbar key, no typing by the key itself — while its release still arrives and the characters no mod cancelled still reach the text box, as vanilla's separatecharTypedcomes for a cancelledkeyPressedtoo. A cancelled character drops only that character and never reaches a text box, and the character hook'sfieldis a copy of the focused box whose edits the box takes through its own filter, length limit and responders; control characters (Enter, Backspace, Tab, Delete, Escape, a Control letter) are keys and fire nothing. A key's characters fire after the key has acted, as GLFW's char callback comes after its key callback, so a key that closes the screen (the inventory key) reports no character (the closed screen hears none) and one that moves the focus types into the box it moved to. The input method's committed text fires the character hook too. They fire for the window and the control socket'skeyandtype(not its\bbackspace), after the fullscreen, screenshot, debug and narrator keys, never on the connect and loading screens, the prompts, books and sign editors drawn over gameplay, or while a world loads or saves. -
native_push_player([x, y, z])on a client, added to the local player's velocity as an explosion's knockback is. -
native_data_pack_files(files), a native mod's own pack files in the data the server loads: while the tables are assembled (a world's first open, and again on every/reload), push(path, bytes)and they join that load.pathis the overlay's key,<kind>/<namespace>:<path>without.json, over the kinds the tables read:recipe/mymod:thing,tags/mymod:block/thing,loot_table/…,advancement/…,trade_set/…,villager_trade/…,dialog/…; a file that isn't JSON is skipped with a warning. Asked once per load on both paths, so it is where a mod recomputes them from its config or the world. Contributed files sit below the world's own data packs: a pack's file for the same path still overrides a mod's, and a later contribution overrides an earlier one. -
Biome colours for client mods, for seasons:
native_biome_colors(biome, colors)changes a biome's grass, foliage, dry foliage and water tints, asked for every biome when a mod callsnative_refresh_biome_colors(), which rebuilds the loaded terrain with them. -
Hidden blocks for client mods (ore scanners, cutaways): the
block_render_visibilityPart method (state,hidden) makes a state draw nothing: the mesher sees it as air, so its neighbours' faces toward it show and it stops hiding what is behind it. Its fluid layer and block entity are hidden too; light, collision and the server are untouched. It is asked once per state when the client joins a world and again when a mod callsrefresh_render_visibility(), which remeshes the loaded terrain. Attach it to vanilla blocks withblock_part_tag. With no part answering, meshing is unchanged.
Screenshots
Hooks::screenshot is the client-thread counterpart of NeoForge 26.3's
ScreenshotEvent. It runs with a ClientHost after F2's GPU readback and before
any file is written. event.image is the in-memory frame (getImage): RGBA8,
top to bottom, width * height * 4 bytes. Change the pixels, or replace the
image, and the normal PNG is that image. A buffer whose length is not
width * height * 4 writes neither file and shows the translated screenshot
failure line. The HDR copy is not this image; it stays the read-back frame.
Return Flow::Cancel to write neither the normal PNG nor its HDR companion.
The chat then shows event.message when it is set, and otherwise the translated
cancellation line (screenshot.failure with horizon.screenshot.canceled).
Change event.file_name to a relative path under the player's screenshots/
folder (for example album/view.png). Missing parent folders are created.
Absolute paths, empty, . or .. components, backslashes, drive prefixes, NUL
and symbolic links, including the HDR destination, are refused with the normal
translated screenshot failure line. The player may make the screenshots folder
itself a symlink. As with other local file operations, this does not isolate a
native mod or defend against a concurrent external filesystem replacement.
event.message starts as None: after a successful save the game builds the
normal translated, clickable success line using the final file name. Set it to
Some(Text) to replace that success line. Save errors always show the failure,
not a listener's success message. A no-op listener preserves ordinary saving.
With HDR enabled the existing tagged 16-bit copy follows the chosen name as
<stem>-hdr.png; its encoding and best-effort error handling are unchanged.
The hook does not intercept control-socket captures or automatic world icons.
It adds no tick work; dispatch runs only when an F2 image has been read back.
Server data reload listeners
Trait mods implement Hooks::add_server_reload_listeners (server only), the counterpart of
NeoForge 26.3's AddServerReloadListenersEvent. Registration runs on world open, /reload
and /datapack changes, after pack selection. The &dyn Host beside the event gives registry
access at registration, as AddServerReloadListenersEvent.getRegistryAccess does. Register an
Arc<impl ServerReloadListener>
with a unique Identifier and FileToIdConverter::json("machines"), or ::new("machines", ".bin")
for another suffix. The listener's ServerReloadContext lists converted IDs and reads their
bytes from data/*/machines/, including a mod's packaged files and later selected datapack
overrides. A file data/other/machines/heavy/press.json becomes other:heavy/press: the directory
and extension are removed, subdirectories preserved, and all namespaces included. It cannot
read assets, other directories, or filesystem paths. Converter directories have no leading or
trailing /, or empty, . or .. components. Converter fields and reload-view fields are private.
The prepare context is the pack view only, as vanilla's ResourceManager. Like vanilla's
SimplePreparableReloadListener, ServerReloadListener::prepare parses and validates without
changing live state and returns <dyn ReloadApply>::new(move || { /* publish parsed state */ }).
All listeners prepare before any apply runs. Do not read another listener's new published
state in prepare: dependencies order both phases but do not remove this barrier. Publication
cannot fail. As vanilla's SimpleJsonResourceReloadListener.scanDirectory, a listener that
parses its files logs and skips one that will not parse, carrying the rest through. Returning
Err from prepare — like any preparation, registration or ordering error — aborts the whole
reload: all pending applies are dropped and the old mod data and vanilla function library are
kept. A panic in prepare or an apply is not recovered: it stops the server, and the crash
report names the listener and the mod that registered it. The error names the namespaced
listener and, for file errors, resource ID; world open fails, and /reload reports the
failure. A failed /datapack change also preserves the selected packs on disk.
event.listeners.add_dependency(first, second) orders named listeners. Both names must
already be registered; unknown names fail immediately, and cycles fail after registration.
VanillaServerListeners::functions() names Horizon's function-library listener; first() and
last() are ordering boundaries. These anchors and dependency ends use Identifier. Mod
listeners run after vanilla by default, in registration order; an explicit dependency before
functions() works without a false cycle. Use these anchors rather than their string values. The read-only get_registry,
get_graph and get_last_vanilla_listener give the registered names, the dependency edges and
the vanilla anchor's name, by value (NeoForge's give the objects; a mod keeps its own listener's
Arc, and its name is the one it registered it under). prepare returns
Box<dyn ReloadApply>; <dyn ReloadApply>::new(move || ...) makes one from a closure. Horizon does not expose NeoForge's condition
context here.
event.listeners.add_retained_listener(&key, converter, listener) also keeps the listener
object for other hooks, the counterpart of NeoForge's addRetainedListener(key, listener): the
caller builds the typed key — let key: ListenerKey<MyListener> = ListenerKey::new(id) — and
reads the object back from any server hook through
host.reload_resources().listener(&key) — an Arc<T> of the live object the last successful
reload installed, as NeoForge's ReloadableServerResources.getListener. Registration rules and
prepare/apply semantics are add_listener's. Retention follows the reload: a new reload
registers fresh objects and the store is replaced whole by the first successful apply, so a key
held across a reload keeps addressing the new object. Before the first successful reload, or
for a key whose id or type does not match a registration, listener gives None.
use std::sync::{Arc, RwLock};
use mod_api::{
self, AddServerReloadListenersEvent, FileToIdConverter, Host, Hooks, Identifier,
ReloadApply, ServerReloadContext, ReloadResourceError, ServerReloadListener,
};
#[derive(Default)]
struct MachineData(Arc<RwLock<serde_json::Value>>);
impl ServerReloadListener for MachineData {
fn prepare(&self, context: &ServerReloadContext) -> Result<Box<dyn ReloadApply>, ReloadResourceError> {
let id = Identifier::parse("my_mod:press").unwrap();
let next = match context.read(&id) {
Some(bytes) => serde_json::from_slice(bytes)
.map_err(|error| ReloadResourceError::new(id, error))?,
None => serde_json::Value::Null,
};
let live = self.0.clone();
Ok(<dyn ReloadApply>::new(move || *live.write().unwrap() = next))
}
}
#[derive(Default)]
struct MyMod(Arc<MachineData>);
#[mod_api::horizon_mod]
impl Hooks for MyMod {
fn add_server_reload_listeners(&self, _host: &dyn Host, event: &mut AddServerReloadListenersEvent<'_>) {
event.listeners.add_listener(
Identifier::parse("my_mod:machines").unwrap(),
FileToIdConverter::json("machines").unwrap(),
self.0.clone(),
).unwrap();
}
}
Reload work only: graph ordering, listing selected files, reading the winning bytes and one boxed apply closure per listener. No new work or filesystem reads run on the tick path. The existing post-success server-data notice below is a different moment, not a second reload registration path.
native_tags_updated(cause)fires once tags changed (NeoForge'sTagsUpdatedEvent), on both sides.TagsUpdateCause::ServerDataLoad { reload }fires once the server's data tables are built: the recipes, tags, loot and advancements of the world's data packs and ofnative_data_pack_filesare what the helpers read from then on.reloadis false on a world's first open, before its chunks and players are loaded, and true after/reload, once the players were sent the new data.TagsUpdateCause::ClientPacketReceived { integrated_server }fires on a client once per set of tags from its server: at login, and for every update in play, on the game thread, with the new tags in the tag helpers. It is where a mod drops caches derived from the data and rebinds what depends on it. In single player both fire, each on its own thread;cause.should_update_static_data()is false only for an integrated server's client packet, so data the two share is updated once, on the server. A remote client's packet returns true.
Client asset reload listeners
Trait mods implement Hooks::add_client_reload_listeners (client only), the counterpart of
NeoForge 26.3's AddClientReloadListenersEvent. Registration runs when a resource reload is
assembled — the player changed resource packs, pressed F3+T, or a server sent its packs —
after the selection is known and before the reload's assets are staged. The &dyn ClientHost
beside the event is the client read host. Register an Arc<impl ClientReloadListener> with a
unique Identifier and the same converters as the server side
(FileToIdConverter::json("ui")). The listener's ClientReloadContext lists converted IDs and
reads their bytes from assets/*/ui/ with resource-pack overrides already resolved: a selected
pack's file wins over the mod's packaged one, and a pack added later wins over both. A file
assets/my_mod/ui/theme.json becomes my_mod:theme. It cannot read data files, other
directories, or filesystem paths. Files resolve like vanilla resource packs: every file
except lang/*.json is delivered as the exact bytes of the winning pack's file, while
language files arrive key-wise merged across the packs, as vanilla loads them.
Prepare and apply semantics, error handling, crash attribution, dependency ordering and the
read-only registry views are the server listener's:
ClientReloadListener::prepare parses without changing live state and returns a ReloadApply;
all prepares finish before any apply, a prepare, registration or ordering error aborts the
reload and keeps every listener's previous assets, and a panic names the listener and its
registering mod in the crash report. VanillaClientListeners::model_manager() names vanilla's
model manager, the anchor of the 26.3 Minecraft registration order; first() and last()
are the ordering boundaries. Mod applies run on the main thread after the staged pack is
published, before client_resources_reloaded. Listeners are registered fresh for every
load: the initial resource load registers them once, before any prepare runs (as
NeoForge's construction-time event does — no game exists yet, and a host read during
registration panics), prepares them beside the rest of the load on the loading thread —
the title screen's loader worker or the direct launch, one path — and the game applies
them once it owns the assets; every later reload — F3+T, a pack change, a server's
packs — registers through the game the same way. A listener whose prepare fails on the
initial load leaves the game running without mod reload listeners, logged as a
warning. NeoForge's client event offers no
retained listeners, and Horizon has none: the Arc the mod registers with is the listener's
own live state.
use std::sync::{Arc, RwLock};
use mod_api::{
self, AddClientReloadListenersEvent, ClientHost, ClientReloadContext, ClientReloadListener,
FileToIdConverter, Hooks, Identifier, ReloadApply, ReloadResourceError,
};
#[derive(Default)]
struct Theme(Arc<RwLock<serde_json::Value>>);
impl ClientReloadListener for Theme {
fn prepare(&self, context: &ClientReloadContext) -> Result<Box<dyn ReloadApply>, ReloadResourceError> {
let id = Identifier::parse("my_mod:theme").unwrap();
let next = match context.read(&id) {
Some(bytes) => serde_json::from_slice(bytes)
.map_err(|error| ReloadResourceError::new(id, error))?,
None => serde_json::Value::Null,
};
let live = self.0.clone();
Ok(<dyn ReloadApply>::new(move || *live.write().unwrap() = next))
}
}
#[derive(Default)]
struct MyMod(Arc<Theme>);
#[mod_api::horizon_mod]
impl Hooks for MyMod {
fn add_client_reload_listeners(&self, _host: &dyn ClientHost, event: &mut AddClientReloadListenersEvent<'_>) {
event.listeners.add_listener(
Identifier::parse("my_mod:theme").unwrap(),
FileToIdConverter::json("ui").unwrap(),
self.0.clone(),
).unwrap();
}
}
Reload work only: graph ordering, listing selected files, reading the winning bytes and one boxed apply closure per listener. No new work or filesystem reads run on the tick path. The existing post-success client-resources notice is a different moment, not a second reload registration path.
native_compute_fov(partial, camera, degrees)receives the computed world FOV after settings, ordinary FOV effects and zoom, before projection/Nausea.camerais its world position andpartialthe interpolation fraction. Mods can narrow or widen the angle; only finite values in 1..=179 degrees are accepted, otherwise the original computed angle is used. It runs for world rendering and near-plane overlays, not just once per frame. Hand/HUD FOV and the title panorama keep their existing paths.
native_movement_input(forward, strafe, jump, sneak) runs on the client each local player tick after input is read and before it is applied. Mods may change all four values; positive strafe is left. The server receives direction flags, not scalar magnitudes, so remote servers may correct modified movement.
-
Mobs drawn by client mods:
native_mob_submit(mob, draw, drawn)is asked each frame for every mob in view but players, with its id, kind, custom name and the frame's body and head turns and walk cycle; a mod draws a model of its own with the block entity drawing helpers underdraw's pose (at the feet, turned with the body) and setsdrawnto leave out the vanilla model. -
A screen of a client mod's own:
native_mod_screen_open(mod_id)opens one over the world (every mod's own screen has theScreenView.idmod: checknative_mod_screen()is yours; it doesn't pause a single-player game; it opens only on the window's own client, over the world with no overlay up; a gamepad can close it but not click on it), the screen hooks fire over it as over any other, so the mod draws it innative_screen_render_postand takes its clicks, scroll, keys and typing from them, and Escape closes it (native_mod_screen()says whose is open,native_mod_screen_close(mod_id)closes it); and two drawing helpers besidenative_fill:native_quad(batch, points, argb)andnative_item_model(batch, item, model, at, size), an icon drawn with anotheritem_model. -
native_camera_distance(partial, mirrored, entity, distance)receives the final scaled maximum distance for a third-person camera, before the existing eight visual-shape collision rays shorten it.entityis the camera entity view, including optional living scale/distance and mount scale/distance; this covers player and spectator-mob cameras.mirroredidentifies front-facing third person. Only finite values in 0..=1024 blocks are accepted, keeping mod requests within the collision-ray budget; other values retain the original distance. First-person views and title panoramas do not fire it. -
native_compute_fog_color(partial, camera, fog_type, rgb)adjusts the final world fog RGB after normal biome, weather and effect calculation and before GPU upload. Camera is its world position; fog_type distinguishes atmosphere, water, lava and powder snow. Only finite components in 0..=1 are accepted; invalid requests retain the original RGB. Alpha and all distance fields are unchanged. Title panoramas skip this hook. -
native_mouse_button_pre(button, pressed, modifiers, canceled)sees real button events on any screen, before normal dispatch. Button and modifiers use the screen hooks' GLFW encoding. Physical button bookkeeping already ran. Cancellation skips normal dispatch and Post. Accepted events callnative_mouse_button_post(button, pressed, modifiers)after dispatch, including screen handlers' early returns. Synthetic pad/focus clicks skip both hooks. Existing screen-specific hooks remain. -
A player respawning:
native_player_respawned(player, name)is heard once at the end ofPlayerList.respawn, with the player placed and their health, food and experience reset (NeoForge'sPlayerRespawnEvent), on a server, with their state swapped in so the helpers reach them. -
Where a player respawns (
PlayerRespawnPositionEvent):native_player_respawn_position(player, original_level, original_position, missing_respawn_block, level, position, yaw, pitch, copy_original_spawn_position), on a server, once the respawn point is found.playeris the player's handle. The mods may change the level, the position, the facing and whether the fresh player keeps its respawn point (default: yes, unless the bed or anchor was missing). Vanilla finds the point insidePlayerList.respawn. Horizon places the player first and finds a bed or anchor's spot, or the world spawn's free column, once its chunks have loaded, so for those the hook fires then, afterplayer_respawned. There a new point in the same level is a teleport, and another level is a level change: one extra Respawn packet, heard asplayer_teleported. An Overworld forced point needs no block: the hook fires at once, before the Respawn packet, and a changed level, point or facing is simply where the player respawns. The point the mods choose is not block-checked and the hook doesn't fire again. Not for the End exit, which isn't a respawn here. -
A client mod's view and items:
native_raycast(distance)andnative_view_hit()(the first block along the view, up to 1024 blocks, across the distant terrain past the loaded chunks), andnative_claim_items(mod_id, ids): a right-click with one of those items is the mod's alone, the server never hears of it, andnative_poll(mod_id)givesUseStartandUseStopwith the stack (its custom name and data as SNBT) and the ticks it was held. -
native_render_fog(partial, camera, fog_type, fog)adjusts final FogData after normal environment/effect/render-distance calculation and Horizon's fog option, before GPU upload. Title panoramas skip it. The whole request is accepted only when color components are finite in 0..=1, all distances are finite, environmental and render-distance ends exceed their starts, and sky/cloud ends are nonnegative. Negative near planes remain valid (water/lava). Invalid requests retain the original whole fog. -
native_screen_key_released_pre(screen, key, modifiers, canceled)runs before a named key's release reaches an open screen, with the same screen exclusions and GLFW modifier encoding as the existing key-press hook. It does not fire for presses/repeats or unnamed keys. Cancel bypasses screen handling, but physical key removal and mapped keyboard/toggle releases still happen to prevent stuck inputs. Mouse mappings retain their existing gameplay-only gate. -
Music for client mods:
native_music(music, volume)changes the track the music manager plays and its volume each tick, andnative_music_layers(layers)lists looping tracks played over it, each easing to its own volume (stems that grow fuller). -
A server's recipes reach its Horizon clients, as NeoForge's
OnDatapackSyncEvent.sendRecipesandRecipesReceivedEvent: on the server,native_datapack_sync(recipe_types)takes the recipe types (minecraft:smelting,mymod:crusher) a mod's client half needs, asked once per load, the same types for every player; Horizon sends their final recipes (data packs merged, load conditions held) to every Horizon client when it joins and after each/reload, in payloads within the clientbound cap, and nothing to a vanilla client. On the client the recipe helpers (native_recipes,native_recipes_of_type) then give the server's recipes of those types in place of the window's own, single player included, andnative_recipes_received(recipe_types)says they came. No type asked: nothing is sent and nothing changes; none asked after a load that asked some: the clients go back to their own recipes andnative_recipes_receivedcomes with no types. -
level.recipe_for(host, &recipe_type, &inputs)matches through the game's crafting, cooking, stonecutting and smithing matchers, including data-pack recipes of those vanilla types.RecipeInputhas Crafting, Single and Smithing variants. ARecipeMatchcarries recipe id, typed output, ordered remainders, experience and processing time. Valid non-matching input isOk(None); malformed input, invalid items and unavailable levels are typed errors. Mod-owned types returnRecipeLookupError::NotMatchable: cacherecipes_of_typeonce per load/reload and match in mod memory, with no per-tick host crossing. Dynamic custom outputs/contextual predicates stay mod-owned. Lookup does not consume inputs; cache the result until the input changes. -
native_input_key(key, action, modifiers)runs after a real window keyboard event finishes handling, including shortcuts, screen consumption and repeats (InputEvent.Key). key is vanilla's name for the physical key (key.keyboard.a;key.keyboard.unknownfor one it doesn't name), action isKeyAction::Press,RepeatorRelease, and modifiers use the screen hooks' GLFW bits, captured before handling. Not cancellable. Foreign-window and OS synthetic focus events skip it. Window callback only: pad, focus and control-socket input are not window key events. -
native_mouse_scrolling(scroll, accumulated, buttons, cursor, modifiers, canceled)fires with a player in gameplay and no open screen/prompt/sleeping UI, after accumulation and before hotbar/spectator handling, including when no whole step has accumulated. scroll is x/y after discrete/sensitivity scaling (touchpad pixels normalized by 30), accumulated is the whole x/y pair, buttons are physical left/middle/right, cursor is the GUI position, and modifiers use GLFW bits. Cancel skips gameplay scrolling; accumulated whole steps remain consumed. Synthetic input follows the same gameplay scroll path; screen-scroll hooks are unchanged. -
native_screen_mouse_dragged_pre(screen, button, delta, modifiers, canceled)runs before screen drag handlers for real nonzero pointer movements with a held button. The most recently pressed button is active until it releases; focus loss and splitscreen handoff clear it. Same screen exclusions and GLFW button/modifier encoding as the other screen input hooks. screen.mouse is the new GUI position; delta is movement in GUI pixels. Cancel skips screen drag handlers while retaining the new cursor position. Synthetic input and hover-only moves skip it. -
The screen render stages (
native_screen_render_pre,native_screen_render_background,native_screen_render_foreground, thennative_screen_render_post) are NeoForge'sScreenEvent.Render.*, on the screensnative_screen_render_postcovers.Render.Precancel skips the screen's own drawing and with it Background and Foreground; Post still fires. Horizon's screens draw their background, contents and tooltips in one function, so Background fires before it all (the screen's panel is over what a mod draws) and Foreground after the tooltips, just before Post, where vanilla's is before them.ScreenEvent.Init.Prehas no hook: Horizon has no screen init method to veto or skip;screen_opening(ScreenEvent.Opening) is the veto point. With no mod subscribed to any of the four, nothing is built per frame. -
The screen input Post hooks (
native_screen_mouse_button_pressed_post,native_screen_mouse_button_released_post,native_screen_mouse_dragged_post,native_screen_mouse_scrolled_post,native_screen_key_pressed_post,native_screen_key_released_post,native_screen_char_typed_post) are NeoForge'sScreenEvent.*.Post, on the screens the_preinput hooks cover and only when the matching Pre did not cancel. They differ in when they fire. The mouse Posts fire after the screen's own arms ran, handled or not; unlike NeoForge's, the press and release carry nowasClickHandled/wasReleaseHandled, because Horizon's screens take their input without reporting it. The key and char Posts fire only when no screen arm took the input (vanilla'skeyPressed/charTypedreturning false), and theircanceledmarks it handled: a cancelled key press or release never reaches the game's key bindings. The char Post does not fire on Options and the title's own screens, whose text is routed in place. A Post fires on the screen open after the input was handled, so one that closed the screen fires nothing. With no mod subscribed, a Post does no work. -
The preedit hooks (
native_screen_preedit_pre,native_screen_preedit_post) are NeoForge'sScreenEvent.Preedit.Preand.Post, fired from the window's input method preedit event while a screen is open. Pre cancel bypasses the screen's handler and Post. Horizon's handler is the focused text field taking the composition, and its real result is what gates Post: Post fires only when no text field took the preedit (no constant). An emptyfull_textis a composition that ended. Nothing fires with no subscriber. -
native_screen_render_inventory_mob_effectsisScreenEvent.RenderInventoryMobEffects: once a frame in the survival and creative inventories' effect panels, after vanilla's early-outs (no effects, or under 32 GUI pixels free), withavailable_space,compact(vanilla's default is under 120) andhorizontal_offsetto change, and cancel to draw nothing. The answer is a screen-build input: the exclusion areas (native_screen_exclusion_areas) follow it in the same frame, with no lag behind a change, and a second build in one frame reuses the frame's answer rather than asking again; no subscriber leaves vanilla's layout. -
native_gather_effect_screen_tooltipsisGatherEffectScreenTooltipsEvent: fires for every hovered rendered effect panel beside the survival and creative inventories.tooltipstarts empty when the panel's information is fully visible, or as the full name then duration for a compact panel or a name cut short. A listener can add a tooltip where vanilla has none, or change, reorder or remove lines; an empty list draws nothing.effectis the panel's effect (aActiveEffect). It is separate fromnative_screen_render_inventory_mob_effects, which picks the layout; hidden panels never dispatch. Screen and effect metadata are built only with a subscriber. -
native_mod_container_viewers(dimension, pos, viewers, previous)is heard once each time the number of players with the mod container atposopen changes (vanilla'sContainerOpenersCounter, as a barrel keeps it):previous0 isonOpen,viewers0 isonClose, so a block can show itself open whileviewersis not 0. It is counted every tick from the players' open menus, so a player who leaves without closing (a lost connection, death, another dimension, out of reach, the block gone) is heard too. On a server, with no player's state swapped in. -
Knockback a mod can change or cancel through
native_living_knockback(NeoForge'sLivingKnockBackEvent): at the start ofLivingEntity.knockback, on the raw strength, before the entity's knockback resistance takes its share and its motion changes, with the hit's level host (its blocks, random and own attachments), the pushed entity's id and type (minecraft:playerfor a player), the push'ssource(HurtSource, named asnative_living_incoming_damagenames a hit's, with the same entity ids left at 0; a goat's ram pushes withminecraft:mob_attackand hurts withminecraft:mob_attack_no_aggro, as in vanilla), theattacker(the attacking entity's network id, falling back to a known last hurt-by mob, or 0 when unknown) and what it holds (attacker_mainandattacker_off: a player's selected and off-hand stacks, a mob's main hand; none when unknown, recorded only while a native mod is loaded), thestrength, the direction (x,z; vanilla points it at the source of a hit, the attacker's side, and the entity is pushed the other way) and acanceledflag. A mod may change the three numbers or setcanceled, and the push follows: canceled, the entity's motion is left alone. Astrengthof 0 or less after the hook is no push, and a direction too short to tell draws a random one after it, as in vanilla; the resistance scales the strength the hook leaves, so a full knockback resistance still stops the push. A value a mod leaves that is not a finite number is put back as it was. It fires for the players, mobs and armor stands the game knocks back: a hit's own push, a sprinting or Knockback hit, a sweep, a spear, a shield's push on its holder and a goat's ram. It does not fire for a sulfur cube with a block that a hit with a causing entity knocks back (SulfurCube.knockbackdoes not callsuperthere), for the pushes vanilla doesn't make withLivingEntity.knockback(the ender dragon's wings, Punch arrows, explosions, wind charges, a mace smash's area, a hoglin's throw), nor yet for the ones Horizon makes another way: the ender dragon's. The hit is in the middle of its work: the level host reads and draws random but can't write the entity or the world. It runs on the thread that ticks the world (the server thread), so state a mod shares with its render thread hooks needs synchronising. -
native_edit_blocks(dimension, edit)for server mods: runs up columns, from a height or a heightmap's, of block states and filters written as commands write them, made at once straight into the chunks of the level being ticked with whatfill ... strictdoes for block entities; the chunks changed go to the players whole. -
Column reads for server mods, in the level being ticked:
native_surface_heights(dimension, columns, heightmap)andnative_read_columns(dimension, columns, heightmap, (below, above)), the blocks round each column's height as a palette and indexes with the biome's grass, foliage and water tints. -
native_experience_orb_can_merge(dimension, into, from, into_value, from_value, can_merge)is asked for each pair of orbs whose boxes meet, when an orb spawns (ExperienceOrb.tryMergeToExisting;fromis 0, the new orb is no entity yet) and every 20 ticks as an orb scans (scanForMerges);can_mergeholds vanilla'scanMergeanswer, which a mod may flip either way.native_experience_orb_merge(dimension, into, from, from_value, canceled)follows a yes and may cancel it: both orbs stay, and a spawning orb tries the next orb in reach, then spawns on its own. A merged orb counts ascountorbs of the keeper's value.native_set_experience_orb(dimension, id, value, count, age)writes an orb'svalue,countandage(each optional; false for acountof 0 or an orb that isn't there; signedvalueis preserved, including zero and negative values). -
native_block_entitygives a block a block entity whatever its template: a mod's stone casing or thin part can use any template. The engine removes it when its block is broken or replaced, no piston moves it, and a mod that draws it (native_block_entity_submit) is asked for it. -
Slot rules for registered vanilla and mod menu types:
menu_slot_rulesstarts with the slot's own answers and may change both, as a NeoForge mod overridesSlot.mayPlaceandSlot.getMaxStackSize. -
An item's use tick and release, as vanilla's
Item.onUseTickandreleaseUsingand NeoForge'sLivingEntityUseItemEvent.Tickand.Stop. Each tick of a use,native_living_entity_use_item_tickmay change the ticks left: at 0 or less, or on a cancel, the server finishes the use, and an item used on release stays frozen until let go.native_on_use_tickis what a mod's own item does then (cook, charge). When the player lets go early,native_living_entity_use_item_stopmay skip the release, andnative_release_usingacts with the ticks left (a bow-like item shoots by its charge); settingusedstarts the item's cooldown. All four run on both sides, as vanilla's do, and take the using entity, None for a player; the tick and stop take the hand too. Change the world only where this game runs the world, not on a server's client. -
native_inventory(uuid, kind): a player's inventory (Player: 41 slots, main, armour, off hand;Ender: 27), live when they are on the server, else from their file, never while they log in, are dead, or have their inventory swapped out. Other guests' live inventories remain available during a turn;native_move_stack((from, kind, slot), expected, (to, kind)): one stack moved, checked againstexpected, where it fits whole, or nothing moves (at most one side a file, written first);native_send_map(uuid, map_id, colors): a 128 x 128 map picture sent to a player, for a filled map with thatmap_id. What an operator's/invseeand a price chart on a map need. -
A loot table's function and condition of a mod's own kind. The entry that names a
typein a namespace outsideminecraft:reachesnative_loot_function(kind, data, ctx, stack)ornative_loot_condition(kind, data, ctx, holds)when the table rolls. The context brings the entity, the damage, the player, the tool, the block, the luck, the biome and a seed from the roll's random. Both hooks answer in the tables the items engine rolls (block drops, entity drops, loot_table, interact_loot), where entity, damage, player and biome are None; the function hook there gets the stack as the main engine's (its item, count, damage, enchantments, potion and saved components) and takes back what it changes, but a function that asks for a map's structure search changes nothing there. An unclaimed function leaves the stack, an unclaimed condition fails, as unknown kinds do. -
A server's levels opening and closing, on the single player server thread and on a dedicated server:
native_level_load(dimension)when a level opens, before any of its chunks (NeoForge'sLevelEvent.Load), andnative_level_unload(dimension)once it has closed, after its last save (LevelEvent.Unload). The level the world opens in comes beforeWorldOpenReady; the Nether and the End open when someone first goes there and close after 300 ticks with nobody in them. Every level still open closes when the server stops, beforeWorldCloseFinal, or when its open or its loop fails, so each load has one unload. -
The client's level made and dropped, on the window:
native_client_level_load(dimension)when the client makes a level, on joining a server (single player's own included) and on changing dimension (NeoForge'sLevelEvent.Loadfrom theClientLevelconstructor), andnative_client_level_unload(dimension)when it drops one, replaced by another dimension's (told after the new one's load, asMinecraft.setLeveldoes) or on leaving the server (LevelEvent.Unload). Each load has one unload, except when the game itself quits. -
A projectile's impact, which a mod can cancel, through
native_projectile_impact(NeoForge'sProjectileImpactEvent): it fires when a projectile has found what it meets, before the hit acts, with the projectile's id, type (minecraft:arrow) and owner, the level, and what it hit: an entity (its id and type) or a block (its position and the face's normal) and the point. Setcanceledand the impact does not happen: no damage, knockback, sound or block reaction, an arrow does not stick, a pearl does not teleport, a potion or a fireball does not burst. The projectile flies on and is not discarded, so a mod that cancels decides what becomes of it. It covers arrows, tridents, thrown items, fireballs, wither skulls, wind charges, llama spit and shulker bullets, on the single player server thread and on a dedicated server. It does not fire for a breeze's deflection, a hit on a wind charge, fireworks or fishing bobbers. A mod hooksnative_hears_projectile_impacts(hears)too and setshears: without it the server keeps no copy of its flying projectiles and the impact hook is not called. -
An entity getting on or off a vehicle, which a mod can cancel, through
native_entity_mount(NeoForge'sEntityMountEvent): the rider's id and type, the vehicle's id and type, whether it is mounting, and acanceledflag. Cancellation keeps the relationship even when a boat or cart is destroyed; the removed vehicle is no longer a valid handle or selector result. A resident Mob retries before its next eligible entity tick, after the tick's cancellable Pre event; a server player retries on its next vehicle tick. A rejected, nonresident passenger does not autonomously retry. Explicit later dismounts ask again. Loading a save does not notify. On the single player server thread and on a dedicated server. -
native_interaction_key_mapping_triggered(key, hand, attempt, swing_hand, canceled)fires while playing when an interaction key is about to act on the crosshair (NeoForge'sInputEvent.InteractionKeyMappingTriggered):keyisInteractionKey::Attack(when an attack is allowed, and each tick a held key keeps digging a block),Use(once for each hand tried, main first, before the hand's target and item) orPickBlock(only with a block or entity aimed at).attemptdistinguishes a queued press from a held mapping. Setcanceledto skip the action; a cancelled use ends the click, so the other hand gets no turn. A cancelled attack or use still swingshandunless the mod clearsswing_hand(true on entry). A single player's window and a server's player (any window that plays on a server). -
native_key_mapping_pre(mapping, pressed, canceled)is asked while playing, before the game acts on a physical key or mouse button bound to a gameplay mapping (key.jump,key.attack...), once for each mapping bound to it.pressedis true for a press (a key's repeats too) and false for its release. Setcanceledon a press to drop it for every mapping bound to that input: the mapping is not held, nothing acts on it, and a mouse press is not counted as a click. A cancel on a release is ignored, so what the game holds is always let go. A release is asked too, while playing: a screen that opens lets go of what it holds without asking. A key bound to two mappings (forward and sprint) is canceled as one. It is asked for the window's keys and mouse buttons, agent control's included, after the window's own shortcuts (fullscreen, screenshot, debug keys) and with no screen open. A controller's presses are not asked. -
native_gui_layer_pre(layer, batch, canceled)andnative_gui_layer_post(layer, batch)fire around each vanilla HUD layer Horizon draws, in its paint order, so a mod places its own drawing below a layer (inpre) or above it (inpost), NeoForge'sRegisterGuiLayersEventregisterBelow/registerAbove, and hides a layer by settingcanceledinpre(RenderGuiLayerEvent.Pre). Layer ids:minecraft:crosshair,hotbar,effects,boss_overlay,vehicle_health(riding a mount),contextual_info_bar(the jump bar, locator bar or experience bar with the level number),player_health(health, armor, food and air rows, one pass),selected_item_name,sleep_overlay,overlay_message(action bar),title,scoreboard_sidebar,subtitle_overlay,chat(the unfocused lines) andtab_list. F1 hides the layers it hides in vanilla, and then nothing fires for them. The camera overlays (drawn by the screen-effects pass) and the debug screen are not layers. A hidden layer still runs its game logic (the health rows' blink, the bubble pop sound) andpoststill fires. -
native_map_decorations(map, decorations)is asked each time a map is drawn (held, in an item frame, or on the cartography table's screen) withMapInfo(id, centre block, scale, dimension, locked, whether it is in a frame); pushMapDecoration { sprite, x, y, rot, shown_on_frame }(an atlas sprite, a position in half map pixels from -128 to 127, a rotation 0 to 15, and whether it shows in frames and on the screen like vanilla's markers) and it is drawn after the vanilla decorations with the same pose; a sprite the atlas lacks is left out. Nothing is saved in the map and vanilla's decorations are not passed in. This is the client half of NeoForge'sMapDecorationType; types registered on the server and saved in map data need code-side registration. -
A player's client starting and stopping tracking an entity, on the single player server thread and on a dedicated server:
native_player_start_tracking(player, name, entity, uuid, kind)when the entity is sent to the player (NeoForge'sPlayerEvent.StartTracking, vanilla'sServerEntity.addPairing) andnative_player_stop_tracking(...)when it stops (PlayerEvent.StopTracking,removePairing): out of range, gone from the level, or the player left, changed level or respawned. They name the entity by ID, UUID and type and run with no player's state swapped in, so a mod sends what the player needs withnative_net_send_to_player. Each start has one stop. -
An energy buffer on a mod block's block entity.
native_block_entity_energydeclares the buffer asSimpleEnergyHandleris built: its capacity, the most one insert and one extract moves, and the faces a fill may come through and a drain may leave by. The amount lives in the entity's saved compound as the intenergy, so it survives restarts and the block's own tick reads it there. The game's own energy capability answers it (see "A block's energy"). -
Data maps (
RegisterDataMapTypesEvent).native_data_map_types(types)pushes the types a mod registers: anid(mod_id:name, not inminecraft) for aregistry,minecraft:itemorminecraft:blockso far; asked once per load of the server's data (a world's first open and every/reload). The values come fromdata/<mod_id>/data_maps/<registry path>/<name>.jsonin the world's data packs and from files a mod pushes throughnative_data_pack_filesunder the keydata_maps/<mod_id>:<registry path>/<name>:{"replace": false, "values": {"minecraft:coal": ..., "#minecraft:logs": ...}, "remove": [...]}, an entry's value being the data itself or{"value": ..., "replace": false};replaceclears what earlier files set, a tag gives every entry in it the value,removeapplies after the file's own values, and a newer value overrides an older one. A typed map (DataMap: its id, its entries' registry and how a value decodes) is pushed withDataMap::ty, andItemKey::data(host, &map)andBlockKey::data(host, &map)read an entry's merged value,Ok(None)where it has none and aDataMapErrorwhere no maps are loaded, no mod registered the map or the value does not decode. Server side only and not synced to clients; mergers, conditions, other registries and mod datapack registries are not here yet.native_data_maps_updated(registry, cause)(NeoForge'sDataMapsUpdatedEvent) fires once for each registry that has a type, right afternative_tags_updated'sServerDataLoad, on a world's first open and after every/reload, withDataMapsUpdateCause::ServerReload: the place to build caches from the new values.DataMapsUpdateCause::ClientSyncis for a client applying synced data maps, which does not exist yet.
native_living_fall(context, entity, kind, distance, multiplier, cancel) corresponds to NeoForge's LivingFallEvent at LivingEntity.causeFallDamage. Mods may change the distance and damage multiplier or cancel fall handling; cancellation does not reverse physical movement. This free function receives the entity ID and type and cannot call game helpers. Player landings, mounted player falls and non-equine mob falls are wired. Equines use their own fall-damage override rather than LivingEntity.causeFallDamage. context is the loop context of the side that ticks the entity; the hook fires inside the landing, so it takes no Host (a deferred call would reorder the fall damage and block effects). farmland_trample and ender_entity_teleport take the context the same way, and so does living_jump, which fires inside the jump. Contract version 2 for all four: a mod written for 1 takes the context as a new first argument of the Hooks method.
native_living_jump(context, entity, kind) listens for NeoForge's LivingEvent.LivingJumpEvent after a living entity jumps. It receives the entity ID and type, and cannot cancel or change the jump. The player motion and mob ground-jump paths, including cubes and ridden horses and camels, are wired; whether every client-side prediction path reaches them remains unverified.
native_living_heal(context, entity, amount, cancel) corresponds to NeoForge's LivingHealEvent at LivingEntity.heal. The amount can be changed and cancellation prevents the heal. It covers player effect, food and peaceful regeneration healing and the wired mob healing paths. Other healing paths and client-side coverage remain unverified. entity is the affected player or mob ID. context is the loop context of the side that ticks the entity; the hook fires inside the health update, so it takes no Host (a deferred call would reorder vanilla's regeneration timers). Contract version 2: a mod written for 1 takes the context as a new first argument of the Hooks method.
native_living_drown(context, entity, kind, is_drowning, damage_amount, bubble_count, cancel) asks on the server when a player or generic-air mob cannot breathe and has no air (NeoForge's LivingDrownEvent, LivingEntity.baseTick). Mods may change the drowning decision, damage and bubble count, or cancel. Damage at or below zero does not hurt. The normal eight bubbles use the entity event; other positive counts are sent in a particle packet, and nonpositive counts suppress bubbles.
-
A player's or mob's breathing, on the single player server thread and on a dedicated server:
native_living_breathe(context, entity, kind, can_breathe, consume, refill)every tick for a player and for a mob with the generic air logic (NeoForge'sLivingBreatheEvent, vanilla'sLivingEntity.baseTick), with Horizon's answers as the defaults: setcan_breatheand how much airconsumetakes orrefillgives (up to 300); at -20 the entity is set to 0 air and takes 2 drowning damage. It is a free function inhorizon_client::breathing, so it answers and cannot call helpers. Axolotls andWaterAnimals keep their own air logic. -
Blocks and items a native mod declares in code, as NeoForge's
DeferredRegister.Blocksand.Itemsdrained byRegisterEvent, instead of one content file each:native_blocks(context, blocks)andnative_items(context, items), asked once at start on every side, takemod_api::BlockandItem, the declarations a content mod'sdata/<id>/horizon/block/anditem/files hold (a vanilla template and what differs;item: truegives a block its item). Ids are in the mod's namespace. They join the content mods' blocks and items, inside the content mod whose id is that namespace when it is on (its models, textures, loot and recipes), else on their own with the template's look, so their network ids, saves and the joining client's content check are the files' (docs/mods.md). They pass the files' checks; a namespace whose declarations don't is logged and left out. -
Entities ticking, through
native_entity_tick_pre(NeoForge'sEntityTickEvent.Pre) andnative_entity_tick_post(EntityTickEvent.Post), with the entity's id and type (minecraft:zombie,minecraft:item) and the level. Setcanceledin the Pre and the entity doesn't tick this tick (and its Post isn't told). Told for the mobs, item entities, minecarts and boats that tick in an entity-ticking chunk, on the single player server thread and on a dedicated server; not for a player's own boat or minecart, projectiles, orbs, primed TNT, falling blocks or armor stands. Checkkind. Mobs and items hear every Pre before the family's first tick and every Post after its last; minecarts and boats hear theirs around each one's tick. A mod that hooks them also hooksnative_entity_tick_listened(listened)and sets*listened = true: otherwise no entity's tick is told. It runs each tick for every ticking entity, so keep it cheap. -
A mob's or an armor stand's equipment changing, on the single player server thread and on a dedicated server:
native_living_equipment_change(entity, kind, slot, from, to)once for each slot (mainhand,offhand,feet,legs,chest,head,body,saddle) that holds another stack than the mods last heard (NeoForge'sLivingEquipmentChangeEvent, vanilla'sLivingEntity.collectEquipmentChanges). A mob new to the level counts from empty on its first tick. A mod that hooks it also hooksnative_living_equipment_listened(listened)and sets*listened = true: otherwise the game does not read every mob's equipment each tick, and only armor stands are told. Players keepnative_on_equip. It only hears. -
native_player_submit(player, who, draw, drawn)fires once a frame for each other player in view and for this window's player in third person (not while invisible), with thenative_mob_submitframe (MobRender, aBlockEntityDrawat the feet turned with the body) andwho(profile name, whether it is the local player); setdrawnto leave out the vanilla model, armour, held items, cape and other layers (RenderPlayerEvent.Pre).native_mob_layer(mob, who, draw)fires once a frame after the vanilla model and layers of every mob and player in view that a mod did not draw itself, and what the mod adds in that frame is drawn with it (EntityRenderersEvent.AddLayers,RenderLivingEvent.Post);whois set for a player. Geometry attaches to the entity's frame, not to the model's bones: head yaw, pitch and the walk cycle are inMobRender. A mod that hooks either also hooksnative_player_render_listened(listened)and sets*listened = true: otherwise neither is asked. -
A player finishing a sign:
native_sign_changed(player, name, dimension, pos, front, lines, cancel)is heard when a player sends the text they wrote on a sign they were editing, right before it goes on the sign, once the checks have passed, on a server, with the player's state swapped in (PlayerRef::tellreaches them). A mod may rewritelines(four lines of plain text) or setcancelso the sign keeps its old text; a waxed sign is not told. -
Models no blockstate names, as NeoForge's
ModelEvent.RegisterStandalone:native_standalone_models(context, models), asked once at client startup (not on integrated or dedicated servers), takes the ids of models a block entity or a mob draws as parts (mymod:block/shaft_half, fromassets/mymod/models/block/shaft_half.json), and every resource load, a pack reload included, bakes them beside the block models. Resolve itsIdentifieronce throughhost.render_assets().model(&id)inclient_setupand again inclient_resources_reloaded, retaining the opaqueModelRefbetween frames.BlockEntityDrawContext::model(model, tint)consumes that handle underdraw's pose innative_block_entity_submitornative_mob_submit, asnative_draw_blockdraws a block's model, withtint(0xAARRGGBB) on its tinted faces, alpha included. A bare0xRRGGBBis alpha 0, so those faces are transparent; pass0xFFRRGGBB, and0xFFFFFFFFfor no tint. Faces with no tint index stay opaque. Missing or unbaked names returnModelError::UnknownModel. Every successful resource replacement, including model-only edits, invalidates old handles withModelError::StaleModel; failed reloads keep them usable. A drawing error appends no geometry. Models use the shared pack stack, including overrides and other namespaces; lookup never reads files or bakes. No block has to be registered just to bake a part's model. -
A player opening and closing any menu on a server, on the single player server thread and on a dedicated server:
native_player_container_open(player, name, dimension, menu, pos)andnative_player_container_close(...)(NeoForge'sPlayerContainerEvent.Openand.Close), with the menu type's name and, for a block's menu, its position. They run with the player's state swapped in. Unlike the viewer count of a mod container (native_mod_container_viewers) they name the player and cover every menu: chests, furnaces, crafting tables, trades, a mod's own. A menu is heard once a tick, so one opened and closed inside a tick is not; each open has one close, also when the player leaves. -
A player starting or stopping flight:
native_player_toggle_flight(player, name, flying, cancel)is heard once for each change a player's client makes to its flying (vanilla honours it only for a player who may fly), on a server, with the player's state swapped in. A mod setscancelto refuse it: the player keeps the state they had and the server sends their abilities back, so the client lands or flies again. Only what a client asks for is heard, not the flight a game-mode change starts or ends, and not a LAN host's own player. -
native_physical_window_size(), the window's width and height in physical pixels, independent of GUI scale. It returns no size on a server or when no window exists. -
a player changing dimension:
native_player_changed_dimension(player, name, from, to)is heard once per move of a server's player into another level, when the move has ended (a portal's exit found and the player standing there, a command's teleport landed, a respawn in another level placed for good), withfromandtoas dimension ids (minecraft:overworld,minecraft:the_nether,minecraft:the_end) and the player's state swapped in so thatPlayerRef::telland the other helpers reach them. A move within a level is not heard. -
native_pickup_xp(player, dimension, orb, value, count, canceled)runs before a server player takes an orb; with no listener the server skips swapping a guest in for it (the sharedlistenedprobe). Canceling leaves the orb and the pickup delay unchanged; otherwise the signed value is preserved, including zero and negative values, and the count is clamped to at least one, then one count is taken.native_mending_repair(player, stack, experience, repair)sees the damaged Mending item picked at random and can change the durability repair proposed for it, which is capped at its current damage. Turning a positive proposal into zero leaves the experience unspent (a zero vanilla proposal keeps vanilla's behaviour); if the picked stack is removed, changed except for its damage, or no longer eligible, the repair is skipped and the experience remains. The repair also decides how much experience is left for further repairs. It runs forPlayerRef::give_experience_pointstoo. Both run on the integrated and the dedicated server, guests included. -
Whom an experience orb follows (
XpOrbTargetingEvent):native_xp_orb_targeting(orb, scan_distance, following), on a server, for each orb as it ticks.followingstarts as the nearest living player withinscan_distance(8) blocks who isn't a spectator, orNone; set another player, orNoneto follow no one. The orb goes only for a living player who isn't a spectator and is in its level, and moves only once they are within 8 blocks. An orb that follows no one gets vanilla's push out of a block it is stuck in. Horizon looks for the nearest player on every orb tick, where vanilla keeps the player it follows while they stay within 8 blocks, so the hook is asked every tick for each orb. Clients still predict the orb's flight towards the nearest player; the server's position wins. -
Where a loot roll happens, for a mod's own loot kinds:
ctx.placeinnative_loot_functionandnative_loot_conditionbringsLootContextParams.ORIGIN(a broken block's centre, a dead mob's position),BLOCK_ENTITY(the broken block's entity as it was, saved with its id, position andItems) and the level (its dimension, game time, time of day, rain and thunder), on a block broken by a player or an explosion and on a mob's death loot; None on the other rolls. The game sets it around a roll withwith_native_loot_place. -
How a mod block burns, and whether it makes a campfire over it a signal fire, on an authoritative server.
native_fire_odds(dimension, state, odds)answersFireBlock's flammability table (FireBlock.setFlammable(block, igniteOdds, burnOdds)) before vanilla's table: put(ignite, burn)inoddsfor your own blocks. Ignite odds above zero let fire stand beside the block and spread to the air around it (planks 5, wool 30); burn odds are how fast fire destroys it (planks 20, wool 60);(0, 0)never burns; keep both within vanilla's 0 to 100. A waterlogged state never burns and isn't asked. It runs hundreds of times per fire tick: keep it short.native_smoke_source(dimension, state, smoke)answersCampfireBlock.isSmokeSourcefor the block under a campfire, as the campfire is placed or that block changes:Some(true)makes the campfire a signal fire, as a hay bale does. Answer only for your own blocks and leave the answer empty otherwise; the level is read-only. The deprecated in-frame path fires neither. -
A server player's game mode about to change:
Hooks::player_game_mode_changedreceives aPlayerGameModeChangedEventbefore the change (NeoForge'sPlayerChangeGameModeEvent) for the/gamemodecommand, the F3+F4 switcher and a hardcore world's respawn into spectator, once for each change to a different mode, on a server; not for the default mode a player joins with. ItsfromandtoareGameModevalues, the same type used by command parsing andPlayerRef::game_mode,set_game_modeandprevious_game_mode. ReturnFlow::Cancelto keep the old mode; numeric ids are only for network and save boundaries. -
The server's difficulty changed, from one of
peaceful,easy,normal,hardto another, throughnative_difficulty_changed(NeoForge'sDifficultyChangeEvent). It is vanilla'sMinecraftServer.setDifficulty, which/difficulty, a dedicated server's management and a test environment's difficulty all go through, and it is told only when the difficulty is another one. It only hears: the change has already happened. -
A player's fishing rod:
native_player_fish(player, name, state, cancel)is heard for the cast (state0), a fish biting (1), the reel-in with a catch (2), the reel-in with nothing on the line (3) and the reel-in of a bobber hooked to an entity or item (4), for a player of a server (Bukkit'sPlayerFishEvent; not NeoForge'sItemFishedEvent). A mod setscancelto stop a cast, make the fish get away, or leave the bobber out on a reel-in. The player's state is swapped in. -
A player took a crafting grid's result, through
native_item_crafted(NeoForge'sPlayerEvent.ItemCraftedEvent): the player's uuid, the recipe the grid matched (minecraft:oak_planks) and the result stack it took, with its components. It only hears; the take has already happened. It fires for the crafting grid of an inventory or a crafting table, on the integrated server and on a dedicated server, guests included, and not for a stonecutter's, a smithing table's or a furnace's result. -
native_server_event(name, uuid, message)posts anannounceevent to a dedicated server's event stream (GET /api/v1/events); horizon-server's Discord bridge relays it, in gold, when[discord] eventslistsannounce(off by default). False elsewhere and for an empty message. -
native_state_block(state)names the block a state belongs to (BlockState.getBlock()), on a server hook's level and on the game:native_state_for_placementis asked for every block placed, so a mod compares it with its own block to answer only for it. -
The helper
native_physical_window_size(), which returns the window's width and height in physical pixels, independent of GUI scale. It returns no size on a server or when no window exists. -
A recipe ingredient of a mod's own kind. The ingredient entry that names a type in a namespace outside minecraft: reaches native_ingredient(context, kind, data, items) at load time. Fill items with the item ids the ingredient matches (item ids only: components are checked by the separate
ingredient_testhook at match time). Duplicates are removed keeping first-seen order. The hook gets theDataLoadContextof the load that parses the recipes and hears each occurrence of the kind once per load, nested ones and ones under any slot a recipe reads an ingredient from included, before the load's tables parse the recipes: the client's own recipe load hasorigin()ClientPreload, the server'sServerDataLoad. A server loads its recipes once however many tables and copies read them (its recipe tables, the stations and the copy synchronised to guests, on a dedicated server too), and a/reloadasks again once; a client that only joins a remote server is never toldServerDataLoad. The answer must be the same every time. An unclaimed kind leaves the list empty and the recipe fails. -
A frame's two moments, as NeoForge's
RenderFrameEvent.Preand.Post:render_frame_pre(host, event)runs once a frame (in a world or not) after Horizon's extras and just before the frame is rendered, andrender_frame_postright after it was rendered; both carry the frame's time asevent.elapsed. They replace the singleframehook, which fired at the pre moment: a mod that implementedframeimplementsrender_frame_pre. Client side; a frame with no listener costs one branch. -
Items a mod's own recipe type takes in a station slot.
native_recipe_property_sets(context, additions)is asked once per load, after the recipes are resolved, with theDataLoadContextof the load that owns them (origin()ServerDataLoadon a server,ClientPreloadon a client's own load), and again after/reload. Push oneRecipePropertyAddition::new(set, items)per set to add to (minecraft:furnace_input,blast_furnace_input,smoker_input,campfire_input,smithing_template,smithing_base,smithing_addition,brewing_input,brewing_reagent); the vanilla recipes' items stay in. The furnace shift-click, the smithing and brewing slots and the set a client is sent read the one answer, so the client predicts what the server accepts. A new key in the listener's manifest-owned namespaces creates a custom set, synchronized to clients alongside vanilla sets. Later listeners may extend an existing set.Host::mod_recipe_property_containsand the sameLoopContextquery take the set key and anItemKey; a mod menu'smenu_slot_rulesreads that generation for both server acceptance and client prediction. These queries cover mod-created sets only, returning false for unknown or vanilla keys. Invalid keys, new foreign keys, unknown vanilla keys and air are logged and skipped; an item the registry does not know is ignored. The answer must be the same every time. -
The in-world HUD's two moments, as NeoForge's
RenderGuiEvent.Preand.Post:render_gui_pre(host, event)runs once a frame in a world, before any vanilla HUD layer, and a mod that returnsFlow::Cancelhides the whole HUD as F1 does (no layer, nogui_layer_preorgui_layer_post, norender_gui_post, no chat or toasts); it is not heard under F1 or while a server dialog or the connection screen covers the window.render_gui_post(host, draw, corners)is the oldhudhook, unchanged: it draws after the minimap, so a mod that implementedhudimplementsrender_gui_post. Client side; a frame with no listener costs one branch each. -
Lowering the frame rate limit:
native_framerate_limit(focused, minimized, in_world, limit)is asked each frame with the limit Horizon is about to use (Max Framerate after the inactivity throttle and Battery Mode, 260 for unlimited), whether the window has focus, whether it is minimised and whether a world is open. A mod may lowerlimit, for example to throttle a window in the background as Dynamic FPS does; Horizon keeps the lower of its own limit and the mod's and ignores values below 1. -
The camera type F5 cycles to:
camera_type_cycle(host, event)runs whenkey.togglePerspectivecycles the window's camera type (Minecraft.handleKeybinds), withpreviousand vanilla'snext(first person, third person behind, third person in front); a mod may setnextto skip or keep a type, and the camera entity's post effect follows the answer.camera_type()reads the type andset_camera_type(type)sets it as F5 does, without a cycle, for a freelook mod that switches to third person and back; withcamera_posemoving the drawn camera, that is a camera independent of the player. The game's two copies ofCameraTypeare now one,mod_api::CameraType. Client side. -
A client tick's two moments, as NeoForge's
ClientTickEvent.Preand.Post:client_tick_pre(host, event)runs at the start of every client tick (Minecraft.tick), paused or not and on the title screen too, andclient_tick_postat its end. They replace the singletickhook, which also fired on a server's ownGame: the server's tick isserver_tick(ServerTickEvent), so a mod that implementedtickimplementsclient_tick_preand, for a server,server_tick. Window only, client side; a tick with no listener costs one branch. -
How long a client tick lasts, a "timer":
tick_target_millis(host, event)gets the milliseconds the window'sDeltaTracker.Timerdivides each frame's time by (Minecraft.getTickTargetMillis), vanilla's 50 at 20 ticks a second or the server's slower/tick rate, and may set another, once a frame before that frame's client ticks run. 25 runs the client twice as fast, 100 half as fast, still at most ten ticks a frame; an answer that is not a finite number above zero keeps vanilla's. Only the window's clock changes; a server keeps its own rate. Client side; with no listener, one branch a frame. -
A player swapping their main and off hand items:
native_player_swap_hands(player, name, cancel)is heard before the swap (Bukkit'sPlayerSwapHandItemsEvent; a swap from an open inventory is not heard), for a player of a server who is not a spectator, with their state swapped in. A mod setscancelto keep the items where they are; the player's client gets its slots back. -
A player's food level changing:
native_food_level_changed(player, name, from, to, cancel)is heard for a player of a server when a turn of theirs changed it (eating, hunger and saturation effects, exhaustion, starving; Bukkit'sFoodLevelChangeEvent), before the new level reaches them. A mod changestoor setscancelto leave the level atfrom. -
A server player teleported:
native_player_teleported(player, name, from, to, dimension)is heard after/tpor/teleporthas moved a player inside their level or into another one (NeoForge'sEntityTeleportEvent), with their state swapped in.fromandtoare their feet before and after,dimensionthe level they are in now. It cannot stop the move; ender pearls, chorus fruit and a spectator's teleport menu are not heard yet, portals and respawns are not teleports here. -
The command tree a server sends a player:
native_player_commands_sent(player, name, names)is heard when a player joins and when their permission level changes, with the root command names their client would get. For a player already on their state is swapped in. A mod removes names and that client's tree is sent without them (its chat box neither completes nor colours them); a typed command still runs,native_player_commandstops it. -
A joining player the mods may refuse:
native_player_login(player, name, cancel, reason)is heard once for each player a server has accepted (name, whitelist, bans and player limit checked), before their saved state is loaded and before they are placed in a level (NeoForge'sPlayerNegotiationEvent). A mod setscancelto disconnect them withreason("Disconnected" when it stays unset); no join message and nonative_player_logged_infollow. -
A server player's sprint:
native_player_sprint(player, name, sprinting)is heard once each time a player's client starts or stops sprinting (the sprint key, a double tap forward, letting go) and the server's player has taken it, with their state swapped in. It only hears what the client asks: a sprint that ends by itself (knockback, hunger, a wall) is not reported. -
A resource pack answer:
native_resource_pack_status(player, name, status)is heard when a server player's client answers a resource pack in play (statusisServerboundResourcePackPacket.Action's number: 0 loaded, 1 declined, 2 failed download, 3 accepted, 4 downloaded, 5 invalid URL, 6 failed reload, 7 discarded), with their state swapped in. The pack offered during the configuration is answered before the player has joined and is not heard. -
A player taking an item from or putting one on an armor stand:
native_armor_stand_manipulate(player, name, stand_id, slot, cancel)is heard after the stand's disabled-slot checks and before anything moves, with the stand's entity id and the slot the click reached (mainhand,offhand,feet,legs,chest,head), on a server, with the player's state swapped in. A mod setscanceland neither the stand nor the player's hand changes. -
A player opening one of the game's own menus:
native_container_opened(player, name, kind, pos, cancel)is heard when a chest, furnace, crafting table, villager, anvil, lectern, horse inventory... is about to open (the job of NeoForge'sPlayerContainerEvent.Open, but before the menu opens), after the checks that refuse it, with the menu type and the block's position, on a server, with the player's state swapped in. A mod setscanceland nothing opens. Menus a mod opens (native_open_menu) are not heard. -
A creative player setting a slot:
native_creative_slot(player, name, slot, item, count, cancel)is heard when the creative screen sets an inventory slot, or throws a stack out (a slot below 0), after the packet passed vanilla's checks and before the slot changes, on a server, with the player's state swapped in. A mod setscanceland the packet is ignored. -
A boat or minecart moving:
native_vehicle_moved(player, kind, from, to)is heard once a tick for each boat or minecart that carries a server player (and for one a server player left, for ten seconds after it stood still), with the vehicle's type and its position before and after (VehicleMoveEvent), on a server, with the rider's state swapped in.playeris the rider's UUID, none for a vehicle that carries nobody now. It only hears. -
A server player emptying a bucket:
native_bucket_empty(player, name, pos, fluid, cancel)is heard right before the fluid is placed, once the player may build there. A mod setscanceland nothing is poured. The player's state is swapped in, so the helpers reach them.native_fill_buckethears the same click without the player and the fluid; dispensers and cauldrons are not heard. -
A server player placing a hanging entity:
native_hanging_place(player, name, kind, pos, cancel)is heard for an item frame, a glow item frame, a painting and a leash knot (a lead tied to a fence that has none), once it fits and before it is placed or the item used. A mod setscanceland nothing is placed. The player's state is swapped in, soPlayerRef::tellanswers them. -
TNT about to be primed:
native_tnt_primed(player, pos, cancel)is heard before a flint and steel, a fire charge, fire, a redstone signal, an arrow or a dispenser lights the TNT atpos, with the player whose click lit it (None when no player did). A mod setscanceland it is not primed: the block stays, or is put back when fire or a signal had already taken it out. The clicking player's state is swapped in. TNT set off by a blast, a TNT minecart and a sulfur cube are not asked. -
A totem saving a player:
native_player_totem(player, name)is heard for a server player whose hurt would have killed them and whose totem fired (LivingEntity.checkTotemDeathProtection, Bukkit'sEntityResurrectEvent), just before the totem is used up. Listen only. The player's state is swapped in. -
native_anvil_damaged(dimension, pos, from, to)runs after an anvil atposchanges fromfromtoto;tois air when it broke. -
native_beacon_state_change(dimension, pos, active)is called after the beacon atposgains or loses its complete beam pyramid;activesays whether it is active now. -
A bell is about to ring:
native_bell_ring(dimension, player, pos, cancel)is asked before the bell event and sound. Setcancelto stop the ring and its raider reveal.playeris the acting server player when available. -
native_block_break_progress_update(player, name, pos, progress)is called when a guest's crack animation atposreaches a new stage from 0 through 9. -
Fire about to burn a block:
native_block_burn(dimension, pos, cancel)is heard when fire's burnout check is about to remove the block atpos. Setcancelto keep it. -
A campfire finishes cooking
itemintoresultatpos, before dropping the result. -
A dispenser equips
itemon the first eligible player, mob or armor stand in front of its block atpos, after the item is consumed and the equip sound and game event are applied.targetidentifies the kind of entity equipped. -
A block with a vanilla experience drop is about to award its points at
pos;blockis its identifier. Changeexperienceto adjust the amount before the orbs are awarded. -
native_block_fade(dimension, pos, from, to, cancel)runs when ice or snow melts, or coral dies.fromandtoare block state IDs. Setcancelto keep the old block. -
native_block_failed_dispense(dimension, pos, item)is called when a dispenser atposfails to dispenseitem. -
native_block_form(dimension, pos, from, to, cancel)runs when precipitation is about to place ice or snow, or add a snow layer atpos.fromandtoare block state IDs. Setcancelto keep the old state. -
Fire about to be placed:
native_block_ignite(dimension, player, pos, cause, cancel)is heard for fire spread, lava, lightning and dispenser flint-and-steel. Setcancelto leave the position unchanged. Direct player flint-and-steel/fire-charge placement is not covered. -
native_block_lock_check(player, name, pos, locked)runs when a guest checks the container atpos;lockedstarts with vanilla's result and may be changed. The guest's state is not swapped in. -
A sculk sensor, calibrated sensor or shrieker received vibration event
eventatpos;blockis its identifier. Setcancelto suppress the block's response. -
A dispenser sheared entity
entityof kindkindat the block in front of the dispenser atpos, after the shearing effects. -
A brewing stand finished brewing:
native_brewing_complete(pos)is heard after the bottles atposreceived their brewed contents. Server-side. -
A brewing stand consumed fuel:
native_brewing_fuel(pos, fuel)is heard after the stand atposconsumed the itemfueland gained brewing fuel. Server-side. -
A brewing stand starts brewing after it consumes fuel and sets its timer.
-
A system broadcast line is about to be logged and sent to every player:
native_broadcast_message(line, cancel)lets a mod rewrite it or setcancelto drop it. -
An item was placed on a campfire and began cooking.
-
A cauldron's fill level is about to change:
native_cauldron_level_changed(dimension, player, pos, from_level, to_level, reason, cancel)is asked for rain, snow, dripstone and extinguishing. Setcancelto leave it as it was.from_levelandto_leveluse 0 for empty and 1 to 3 for filled levels.playeris the acting server player when available. Bucket, bottle and dyeing interactions are not covered yet. -
A player adds a compostable item to a composter at
pos; the layer has been applied. -
A crafter at
poscompletedrecipe; item consumption and output handling have finished. -
A player used an item in
minecraft:creeper_igniterson a creeper, after it entered its ignited state. -
Lightning struck a creeper and set its charged state; the hook reports the creeper id and strike position.
-
The first Ender Dragon fight is about to place its egg at
pos:native_dragon_egg_form(dimension, pos, cancel)runs onGameLevel. Setcancelto leave the block unchanged. -
A player applied an enchantment:
native_enchant_item(player, name, pos, item, levels)is heard after the player atposapplied it, with the finisheditemand levels charged. The guest's state is not swapped in. -
Saved entity records for a chunk finished loading into the server world:
native_entities_load(dimension, chunk, count)reports the dimension, chunk coordinates and number of records read. -
Entities in a chunk left the active world and were queued for saving:
native_entities_unload(dimension, chunk, count)reports the dimension, chunk coordinates and number of records. -
A zombie or raider finished breaking the door at
pos; the mob id and entity type are reported after the door is removed. -
Two mobs completed a breeding action:
native_entity_breed(parent, partner, child)reports both parents and the baby id when one was created; egg laying has no child id. -
A mob changed the block at
pos; the hook reports the mob id and kind, and the old and new block names after the new state was applied. -
A bee was stored in a beehive or bee nest at
pos; the hook reports the bee id and kind and the block name, after the bee was removed from the level. -
A mob entered love mode after a player interaction; the hook reports its entity id, kind and feeder UUID when known.
-
A server entity finished its portal wait and is beginning to leave through the portal block at
pos:native_entity_portal(dimension, entity, pos)reports the entity's network id, source dimension and entry block. The transfer destination follows the existing portal rules. -
A guest player's synchronized pose changed:
native_entity_pose_change(player, name, from, to)reports the old and new pose names. -
A player fired an arrow from a bow or crossbow:
native_entity_shoot_bow(player, name, projectile, force)reports the arrow id and shooting power after the completed shot. -
A mob became tamed after its owner and persistence were set:
native_entity_tame(entity, kind, owner)reports the entity type and owner UUID when known. -
A server entity is about to be teleported by
/tpor/teleport:native_teleport_command(dimension, entity, from, to, cancel)(NeoForge'sEntityTeleportEvent.TeleportCommand) names the level it arrives in, its network id, its feet now and where the command sends it. A mod may moveto(a value that is not a number is ignored);cancelleaves the entity where it is. It fires once per teleported entity, beforenative_entity_teleportandnative_player_teleported, which hear the move after. -
A server entity completed a same-level teleport command:
native_entity_teleport(dimension, entity, from, to)reports its network id and the coordinates before and after the move. -
A guest player's fall flying state changed after the server accepted or ended a glide:
native_entity_toggle_glide(player, name, gliding). -
A mob conversion completed through Horizon’s shared
Mob.convertTopath; the hook reports the removed entity and its replacement ids and kinds. -
An experience bottle broke, after its water splash and experience orbs were created; the hook reports the impact position and awarded experience.
-
A firework rocket completed its damage, particles and explosion effects; the hook reports its entity id, position and number of burst shapes.
-
native_fluid_level_change(dimension, pos, fluid, from, to, cancel)is called when flowing water or lava atposis about to change level; levels run from 0 through 8. Setcancelto keep the old level. -
A furnace consumes
fueland begins burning atpos(native_furnace_burn). -
A server player takes
countitems ofitemfrom a furnace atpos(native_furnace_extract). -
A furnace finishes smelting
itemintoresultatpos(native_furnace_smelt). -
A furnace begins cooking
itematposwhen its cooking timer first advances (native_furnace_start_smelt). -
A hopper is about to pick up a dropped stack:
native_hopper_pickup_item(dimension, pos, item, count, cancel)names its position and stack. Setcancelto leave the item in the world. -
native_inventory_block_start(player, name, pos, cancel)runs before a guest opens an inventory supplied by a block atpos. Setcancelto leave it closed; the guest's state is not swapped in. -
A dropped item entity was successfully added to the server level:
native_item_spawn(entity, item, count, pos)reports its entity id, item type, stack count and spawn position. -
native_leaves_decay(dimension, pos, cancel)is called when a non-persistent leaf block at distance 7 is about to decay. Setcancelto keep it. -
A weather, Channeling-trident or
/summonlightning bolt is about to be created:native_lightning_strike(player, pos, cause, cancel). Setcancelto prevent the bolt and its effects. -
native_moisture_change(dimension, pos, from, to, cancel)runs when farmland atposis about to change moisture fromfromtoto. Setcancelto keep the old moisture. -
A server player put a plant into a flower pot:
native_player_flower_pot_manipulate(player, name, pos, plant)is heard after the potted state is stored and the item consumed. -
A server player inserted a book into a lectern:
native_player_insert_lectern_book(player, name, pos, book)is heard after the block entity and inventory changed. -
A server player changed an item frame:
native_player_item_frame_change(player, name, entity_id, action, item, rotation)is heard after its contents and comparator output changed. -
An orb repaired an item through Mending:
native_player_item_mend(player, name, item, repair, experience)is heard after its damage changed. -
A server player changed a lectern page:
native_player_lectern_page_change(player, name, pos, page)is heard after the page and redstone pulse changed. -
A server player took a book from a lectern:
native_player_take_lectern_book(player, name, pos, book)is heard after its block entity changed. -
native_spawn_change(pos, angle)is told after/setworldspawnmoves the world's spawn point, with its block position and rotation. -
native_world_border_bounds_changed()is told after/worldborder setor/worldborder addchanges the border size or starts its timed size change. It hears successful commands only. -
native_world_border_center_changed()is told after/worldborder centermoves the border center. It hears successful commands only. -
A server player completed an advancement after progress was recorded;
advancementis its identifier, such asminecraft:story/mine_stone. -
A server player's main-hand punch swing and attack-strength reset have happened:
native_player_attack_cooldown_reset(player, name). -
A server player about to enter a bed after its sleep checks pass:
native_player_bed_enter(player, name, pos, cancel)hears the bed head position. Setcancelto keep the player out. The player's state is swapped in. -
A server player woke or stood up from the bed at
pos, after their sleeping state changed. -
A server player caught a bucketable mob:
native_player_bucket_entity(player, name, entity_id, entity)runs after the mob was removed and the filled bucket was put in the player's hand or inventory.entityis the mob species id. -
A server player's selected beacon effects were stored:
native_player_change_beacon_effect(player, name, pos, primary, secondary). The primary and secondary values areOption<EffectKey>;Nonemeans no effect is selected. -
A server player counts as deeply asleep after 100 ticks in a bed:
native_player_deep_sleep(player, name)fires once at that point. -
A server player book-and-quill packet is about to be applied.
pagesis its page count andsigningsays it has a title; setcancelto drop it. -
A server player threw an egg after its projectile was launched and its use applied.
-
A server player used a firework rocket to boost while gliding, after the rocket was launched and consumed.
-
A server player's XP orb pickup cooldown changed, after its new value is stored (
native_player_exp_cooldown_change(player, name, ticks)). -
An item cooldown about to start for a server player:
native_player_item_cooldown(player, name, item, ticks, cancel)is called for use cooldowns and for a shield disabled by a hit.itemis the item id andticksits duration. Setcancelto skip the cooldown; shield disable still stops the use and plays its configured sound. The player's state is not swapped in. -
A server player switches hotbar slot (
PlayerSwitchHotbarSlotEvent), only when the slot differs:native_player_switch_hotbar_slot_pre(player, player_entity_id, old_slot, new_slot, cancel)is called after the slot range check and before the selected slot changes. Setcancelto keep the old slot; the client is sent that slot again. Otherwisenative_player_switch_hotbar_slot_post(player, player_entity_id, old_slot, new_slot)follows once the slot changed and a main-hand item use stopped. The player's state is not swapped in. -
A server player selected loom pattern button
pattern, after the menu accepted it:native_player_loom_pattern_select(player, name, pattern). -
A server player filled an empty map:
native_player_map_filled(player, name, map_id)fires after the filled map and its saved map data were created. -
A server player named a mob with a name tag, after its name and persistence changed:
native_player_name_entity(player, name, entity_id, entity_name). -
A server player opened a sign editor at
pos, for the front or back side, after the editor state was made:native_player_open_sign(player, name, pos, front). -
A server player's middle-click pick resolved a block or entity's pick stack and is about to put it in the hotbar:
native_player_pick_item(player, name, from_entity, pos, entity_id, cancel)receives block coordinates and entity_id zero for a block, or zero coordinates and the entity network id for an entity. A pick with no result is not heard. Setcancelto leave the inventory and client unchanged. The player's state is swapped in. -
A server player picked up an arrow after it was removed and its pickup was applied:
native_player_pickup_arrow(player, name, entity_id, item)gives the pickup item id. -
A server entity about to travel to another level, a player or not:
native_entity_travel_to_dimension(entity, dimension, cause, cancel)(NeoForge'sEntityTravelToDimensionEvent) names the target level;causeisnether_portal,end_portal,end_gatewayorteleport. Setcancelto keep the entity where it is. The player's state is not swapped in. -
A server player requests a known recipe be placed in their crafting grid through the recipe book (
native_player_recipe_book_click(player, name, recipe, all)). -
A recipe about to become known to a server player:
native_player_recipe_discovered(player, name, recipe, cancel)is heard for advancement rewards, knowledge books and/recipe give. Setcancelto keep it locked; the client is sent the matching removal. The player's state is not swapped in. -
A server player launches with a riptide trident:
native_player_riptide(player, name)fires after the velocity and spin are applied. The player's state is swapped in. -
A server player about to shear a sheep, mooshroom, snow golem or bogged.
off_handsays which hand holds the shears; setcancelto leave the mob and tool unchanged. -
A server player's shield was disabled by a blocked melee hit:
native_player_shield_disable(player, name, ticks)fires immediately before the shield cooldown is set. -
A server player selected stonecutter recipe button
recipe, after the menu accepted it:native_player_stonecutter_recipe_select(player, name, recipe). -
A spectator's camera returned to their own player after they stopped watching an entity:
native_player_stop_spectating_entity(player, name, entity_id)gives the former target. -
A server player swapped their main hand with an equipment slot:
native_player_swap_with_equipment_slot(player, name, slot)fires after both hands changed;slotis currentlyoffhand. -
A player is about to travel through an End gateway (
native_player_teleport_end_gateway(player, name, gateway, cancel)). Setcancelto leave them at the entrance without starting the gateway cooldown. -
A server player used a golden dandelion on an ageable mob:
native_player_toggle_entity_age_lock(player, name, entity_id, locked)fires after the lock and item use changed. -
A server player completed a villager or wandering trader trade, after the merchant menu applied the trade:
native_player_trade(player, name, entity_id)gives the merchant's network id. -
A server player's action removed an entity's leash:
native_player_unleash_entity(player, name, entity_id)fires after the leash state is cleared. -
The server synced changed motion to a player:
native_player_velocity(player, name, velocity)fires with velocity in blocks per tick. -
native_portal_create(dimension, pos)is told after a Nether portal for dimension travel or the End arrival platform has been built, with its dimension and lower corner or platform origin. -
A thrown splash or lingering potion finished applying its hit effects; the hook reports the potion item and impact position. Experience bottles are excluded.
-
A crafting-grid result was prepared:
native_prepare_item_craft(player, name, pos, result)is called for the inventory grid (None) or a table atpos; change the result before it is sent to the menu. Guest state is not swapped in. -
Enchanting offers were calculated:
native_prepare_item_enchant(player, name, pos, costs)is called for a server player atpos; change the three costs before they are sent to the menu. Guest state is not swapped in. -
A smithing result was prepared:
native_prepare_smithing(player, name, pos, result)is called for a server player atpos; change the result before it is sent to the menu. Guest state is not swapped in. -
A projectile was added to the server level:
native_projectile_launch(player, name, entity, kind)reports the entity id and type, plus the player launcher when there is one. -
A raid wave has spawned at its position, with its raid id, wave number and raider count (riders' mounts not counted).
-
A raid is triggered or reaches
victory,lossorstopped:native_raid_state_change(raid, pos, state)reports its owningRaidRef, center and new state. The handle retains its level even when that level is parked; an already removed raid cannot be read. -
native_sculk_bloom(dimension, pos)is called after a sculk catalyst atpostakes experience and enters the blooming state. -
native_sponge_absorb(dimension, pos)is called after a sponge atposhas absorbed water and become wet. -
A guest's server command completion request was evaluated:
native_tab_complete(player, name, command, range, suggestions)lets a mod add or filter the suggestions;rangeis the replaced byte range. The guest's state is not swapped in. -
native_target_hit(dimension, pos, strength)is called after a projectile hits the target block atposand it takes the reported redstone output strength. -
The server's
/weathercommand is about to change thunder:native_thunder_change(dimension, thundering, cancel). Setcanceland the previous thunder state and timer remain. -
The overworld clock is about to jump after sleep or a
/timecommand:native_time_skip(dimension, from, to, cause, cancel). Setcancelto keep the old time. -
A player selected a trade:
native_trade_select(player, name, villager, index)is heard after the trading menu updates;villageris the entity id when known. The player's state is not swapped in. -
native_vault_change_state(dimension, pos, state)is called after a vault atposchanges tostate(inactive,active,unlocking, orejecting). -
The server's
/weathercommand is about to change its rain and thunder parameters:native_weather_change(dimension, raining, thundering, cancel). Setcanceland the prior weather and timers remain. -
The dedicated server's whitelist changed state:
native_whitelist_state_update(enabled)reports its final on/off state, after/whitelist onor/whitelist offfrom a player or console. -
The server completed a world save cycle:
native_world_saved(wait)reports whether chunk writes were flushed before returning. -
The time players wake at after sleeping, as NeoForge's
SleepFinishedTimeEvent:native_sleep_finished_time(dimension, current, new_time)runs on the server once enough players have slept and theadvance_timerule is on, before the clock moves.new_timecomes in as the next morning and a mod may change it; a time beforecurrentis refused.native_time_skipthen hears the jump to the chosen time. -
A shift-click in a mod container's menu routed by the mod,
AbstractContainerMenu.quickMoveStack:native_menu_quick_move(pos, entity, container_slots, slot, stack, handled)runs on the server before the game's own routing, for a mod container on a vanilla menu type or its own menu type. The mod takes what it moves out ofstackand setshandled; the slot keeps the rest and the client gets the menu again. -
A server player's trade with a villager or a wandering trader, as NeoForge's
TradeWithVillagerEvent:native_trade_with_villager(merchant, kind, offer)is heard once per trade made, after its statistic andvillager_tradetrigger, with the trader's entity id and type and theMerchantOfferas it is after the trade. Listen only; the player's state is swapped in. -
A server player's or fox's
teleport_randomlyconsume effect (chorus fruit) is about to try a spot: version 2 ofnative_item_consumption_teleport(consumer, consumed, from, target, cancel), once per attempt (up to 16).consumeris the living actor's handle andconsumedis the public API stack captured before the use, with its count, damage and component patch, rather than the current contents of the hand. The living handle replaces version 1's player-only UUID/name fields; players retain their acting-player context. A mod may movetarget; the game still looks down from it for ground to land on.cancelends the effect with no teleport and no more attempts, not consumption. A fox's callback runs with the full server host before its living AI/movement and before the next mob ticks; listener changes to its mouth item, position or eligibility invalidate the captured decision rather than being overwritten. -
A server player's or mob's ender pearl landed: version 2 of
native_ender_pearl_teleport(owner, pearl, from, target, damage, cancel)runs before teleporting the liveEntityRefowner. A mod may movetarget; cancellation spends the pearl without a teleport, sound, player damage or endermite roll. A player retains acting-player context and mutable fall damage (5). A mob preserves rotation, resets fall distance, takes no player fall damage and makes no endermite draw. A listener's move or an owner that is no longer eligible invalidates the pending move; it is refused rather than overwriting that write.ownerreplaces version 1's player-only UUID/name fields; use its handle and the host services. Named injections must requesthook_version = 2; no compatibility fields or version-1 alias are retained. -
Drawing an item stack on a screen:
native_item_stack(batch, stack, at, size)draws its model, glint, damage bar, cooldown and mod decorations, but not its count. It draws nothing and returns false for an unknown item or an empty stack (air, or a count of 0). -
Hiding a player (a vanish):
player.hide(host, level)hides that player, from the next tick, from the players below permissionlevel(None shows them again): they leave those players' Tab list and world (their clients stop tracking them, as they do a spectator) and those players don't read them joining or leaving. Players oflevelor more still see them; a player made an operator sees them again. Retain aPlayerRefobtained while the player is available: unlike live-player writes, hide can still change that UUID's visibility after disconnect, within the handle's world epoch. It cannot target an arbitrary offline UUID, and a handle from a closed or reopened world refuses the write. The visibility setting is kept by UUID across reconnects until changed or the server stops. It requires&mut dyn ServerHostand an available server player list. -
A player's item breaking:
native_player_destroy_item(player, name, item, slot)is heard for a server player whose tool, shield, rod or armour piece ran out of durability (LivingEntity.onEquippedItemBroken, Bukkit'sPlayerItemBreakEvent), with the item that broke and the slot it was in (mainhand,offhand,head,chest,legs,feet,body,saddle); armour has a slot rather than a hand.ItemPart::damage_itemis the wear, this is only the break. Listen only. The player's state is swapped in. Not heard for a creative player or for the host of a LAN world. -
A player's damaging melee hit on a living target:
native_critical_hit(target_kind, vanilla_critical, critical, damage_multiplier)may force or deny the critical hit and change its multiplier (1.5 for a vanilla crit, else 1.0); the sweep, sound and particles follow the decided result. Not fired for a dragon part or a hit that does no damage. -
Attachment reads that never insert the default:
AttachmentType::hassays whether a holder carries a value, saved or set, even before it decodes (NeoForge'shasData), andAttachmentType::get_existingreturnsNoneinstead of the default (NeoForge'sgetExistingData). Neither adds anything to the holder's saved data. -
A player releases a bow:
native_arrow_loose(stack, has_ammo, charge, canceled)may change held-tickchargeor cancel the shot; negative charge also prevents it. Unlikenative_living_entity_use_item_stop, it does not skip the whole item release;native_entity_shoot_bowreports the spawned arrow afterward with read-only force. Fires on the Game running the bow release, including LAN guests; a remote client's own release path, crossbows, and ArrowNockEvent are not covered. -
An item frame or painting about to break:
native_hanging_break(player, kind, pos, cancel)is heard before a server player's hit, a blast or the loss of its wall breaks an item frame, a glow item frame or a painting, with the hitting player (None for a blast or a lost wall). A mod setscanceland it stays. The hitting player's state is swapped in. A hit that only takes a frame's item out, the game's own player and/killare not asked. -
A criterion of a server player's advancement was granted or revoked after its progress changed and before any completion rewards, except the
minecraft:tickcriteria granted on a network player's first tick or a reload, which are heard after that tick's rewards and packets;advancementis its identifier, such asminecraft:story/mine_stone,criterionthe criterion name andprogress_typeisProgressType::GrantorProgressType::Revoke. A grant or revoke that changes nothing is not heard. -
A statistic about to be awarded to a server player:
native_player_stat_award(player, name, stat_type, stat, value, cancel), withstat_typesuch asminecraft:minedorminecraft:custom,statsuch asminecraft:stoneorminecraft:walk_one_cm, andvaluethe amount added. Setcancelto award nothing: the count and its scoreboard criteria stay as they were. -
A player's data copied to their new player:
native_player_clone(player, name, was_death)is heard fromServerPlayer.restoreFromonce for each death respawn (was_deathtrue) and for each exit from the End after the credits (was_deathfalse), after the retained data is copied and before the new player is placed (NeoForge'sPlayerEvent.Clone). The uuid stays the same, soplayernames both the old and the new player. It cannot change or stop anything; no player's state is swapped in. -
A player loaded from the world's save:
native_player_load_from_file(player, name, directory)is heard once for each join (the owner of an integrated server too), after the player's.datis read and applied and before they are placed in a level (NeoForge'sPlayerEvent.LoadFromFile,PlayerList.loadPlayerData).directoryis the world's player directory (players/data); a mod reads its own file there, named<uuid>.<suffix>. It cannot change or stop anything; no player's state is swapped in. -
A player saved to the world's store:
native_player_save_to_file(player, name, directory)is heard once for each save of a placed player (when they leave or the world closes, when the dedicated server saves everyone, and for an integrated server's owner when the world saves them; LAN guests are not heard on integrated autosaves) after their data is written (NeoForge'sPlayerEvent.SaveToFile,PlayerList.save).directoryis the world's player directory (players/data); write your own file there, named<uuid>.<suffix>, never the player's.dat. The player may be leaving, so no player's state is swapped in. It cannot change or stop anything. -
native_item_expire(entity, stack, extra_life), a dropped item reaching its lifetime on the server (NeoForge'sItemExpireEvent, vanillaItemEntity.tick): every mod may set or add toextra_life, in ticks from zero; a positive total keeps the item that much longer, zero or less lets it expire. Extensions are capped at 32766 ticks. -
The title's own screens to the screen hooks.
ScreenView.idhas one id per title state (title,world_select,world_create,world_create_flat,world_create_buffet,world_preset_editor,world_edit,world_backup,world_backup_confirm,world_optimize_confirm,world_optimize,low_disk_space,world_delete,world_recover,world_recovered,error,multiplayer,add_server,edit_server,direct_connect,server_delete,friends_join,disconnected,multiplayer_warning,onboarding,ban_notice), with nomenu, notitleand noslots, and the whole window asrect.native_screen_render_postfires after the title draws its screen (before its toasts), and the_preinput hooks — mouse buttons, drag, wheel, key press and release, character typed — fire on it and can cancel it as on any screen, so a mod draws its own button or browser over Create New World and takes the clicks. A dialog, a prompt or a server's pack prompt over the title keeps them away, and typing still goes to the title's own boxes (with no box focused the key's text stays on the key, as on Options, sonative_screen_char_typed_prefires before the key acts there).native_instance_dir()is the instance folder, whereoptions.txt,saves/,resourcepacks/,shaderpacks/andhorizon-mods/are; a world'sdatapacks/is in that world's folder. -
A player took an anvil's result:
native_anvil_craft_post(player, name, pos, craft)(NeoForge'sAnvilCraftEvent.Post) is heard after the levels, the inputs and the anvil wear are done, so a broken anvil is already air.craftholds the inputs and the result as they were before. Not heard whennative_anvil_craft_precancelled. -
A player took an anvil's result:
native_anvil_craft_pre(player, name, pos, craft, cancel)(NeoForge'sAnvilCraftEvent.Pre) runs before the levels are charged, the inputs used up and the anvil worn.craftholds the left and right inputs and the result as they were before. Setcancelto refuse the take and skip the matchingnative_anvil_craft_post: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. -
A server player stopped digging
posbefore the block broke:native_block_damage_abort(player, name, pos)is heard after the server clears the dig and sends the block state back to the client. The player's state is not swapped in. -
A mob is about to drop
countofitematpos:native_entity_drop_item(entity, item, count, pos). -
A mob's leash was removed, before any lead drop is handled:
native_entity_unleash(entity, kind). -
A server explosion is about to begin at
centerwithradius(native_explosion_prime); the hook is listen-only and the explosion proceeds as usual. -
A hopper selects a container while searching for items:
native_hopper_inventory_search(hopper, container, entity)identifies the hopper and the selected block container or container entity. -
A hopper is about to move one item between containers:
native_inventory_move_item(source, destination, item, cancel)runs before the source slot is changed, a composter's bone meal included; a chest boat or minecart is reported at the block it is in. Setcancelto keep the item in its source slot. -
A dropped item entity reached its 6000-tick lifetime and is about to be removed:
native_item_despawn(entity, item, count, pos). -
A boat or minecart joined the level at
pos, after a player or dispenser placed it:native_vehicle_create(kind, pos)is listen-only. -
A player is about to damage a boat or minecart:
native_vehicle_damage(kind, damage, cancel)may change the damage or cancel it before the vehicle's damage and wobble change; the attack still completes. -
A boat or minecart is about to be destroyed at
pos:native_vehicle_destroy(kind, pos)is heard just before it is removed; TNT minecarts that prime instead are not heard. -
A guest's arm swing:
native_player_arm_swing(player, name, hand)is called after the server handles the guest'sPunchpacket and resets attack strength. The current packet swingsmainhand; the guest's state is swapped in. -
A server player selected the item at
indexin a bundle in menuslot:native_player_bundle_item_select(player, name, slot, index). -
A server player's client finished loading the level around them after joining, respawning or changing dimension:
native_player_client_loaded_world(player, name). -
A guest's player input flags:
native_player_input(player, name, flags)is called after a loaded guest'sPlayerInputpacket is applied to the hub.flagsis the packet's bit field; the guest's state is swapped in. -
A guest jumped:
native_player_jump(player, name)is called after accepted movement made the server player jump from the ground, once the guest's movement simulation finished. The guest's state is swapped in. -
A guest was kicked from the server:
native_player_kicked(player, name, reason)is called after the hub records the rendered reason and sends the disconnect. The guest's state is swapped in. -
A LAN guest launched a snowball, egg, ender pearl, potion or trident:
native_player_launch_projectile(player, name, item)runs after the projectile is created. -
A server player's experience level changed:
native_player_level_change(player, name, from, to)is called after experience points or levels change their level, including orb points. The player's state is swapped in when they are a guest. -
A server player released a drawn bow, by the use-item key or a mod's
release_using_item, withnative_player_ready_arrow(player, name, arrow): the name of the selected projectile item. -
A server player's recipe book open or filter setting changed:
native_player_recipe_book_settings_change(player, name, book, open, filtering), wherebookis its typedRecipeBookIdentity. -
A server player released the use-item key, or a mod's
release_using_itemdid, before the item finished being used:native_player_stop_using_item(player, name). -
A guest's player input changed sneaking:
native_player_toggle_sneak(player, name, sneaking)is called after the hub applies the new state, only when it changes. The guest's state is swapped in. -
A hopper begins a transfer attempt:
native_prepare_hopper_transfer(pos)runs after its cooldown and enabled checks, before it pushes or pulls items. -
A vault is about to eject a loot item:
native_vault_loot(pos, item)supplies the vault position and item identifier before it spawns. -
A timed world border size change finished:
native_world_border_bounds_change_finish()fires after the border reaches its final size. -
Game::native_mod_image(width, height, rgba): a mod's own RGBA image, uploaded into the HUD atlas slothorizon/modimage(1024 by 1024) and drawn withnative_sprite, whose source rectangle crops and stretches it, so a map or a schematic view needs no texture of its own. The slot is reserved only while a native mod is loaded, so a game without mods pays none of its 4 MiB. False for an image that is notwidth * heightpixels of four bytes, is larger than the slot, or when there is no slot. Each upload is a texture patch of the image, so send one when the image changed, not every frame. -
A living entity drowning (
native_living_drown), with changeable drowning and damage, a read-only bubble count and cancellation. -
Game::native_translate(key): the selected language's text for a translation key, orNonewhen no language layer defines it (I18n.exists), so a mod can fall back its own way (a recipe viewer names an item tag bytag.item.<namespace>.<path>and falls back to the id). NeoForge:I18n.existsandI18n.get. Client-side. -
Game::native_enchantments(),Game::native_enchantment_supports(id, item)andGame::native_enchantment_min_cost(id, level): every registered enchantment as anEnchantmentInfo { id, max_level, curse }record in registry order, whether one can go on an item (Enchantment.isSupportedItem), and the power a level needs (Enchantment.getMinCost), orNonefor an unknown enchantment. What a recipe viewer lists for grindstone and anvil enchanting. NeoForge: theenchantmentregistry (RegistryAccess.lookupOrThrow(Registries.ENCHANTMENT)),Enchantment.getMaxLevel,Enchantment.isSupportedItem,Enchantment.getMinCost, theminecraft:cursetag. Client-side. -
Game::native_fluid(batch, fluid, [x, y], size)andGame::native_fluid_name(fluid): a fluid (minecraft:water,mymod:oil) as a square ofsizeGUI pixels, the GUI sprite its look names (data/<mod>/horizon/fluid/<name>.json) tinted with its colour, else a flat square of the colour (vanilla water and lava have theirs), and its name as the lang gives it. What a recipe viewer draws for a fluid stack; the slot, the amount and the tooltip are the mod's. NeoForge:IClientFluidTypeExtensions.getStillTextureandgetTintColor. Client-side. -
A living entity's fall (
native_living_fall), with changeable distance and multiplier and cancellation. -
A living entity healing (
native_living_heal), with a changeable amount and cancellation. -
A living entity jumping (
native_living_jump).
Hook and helper capabilities
-
ClientHost::panel_itemsshows an item panel beside container screens;panel_eventsupplies the player's clicks, searches and keys over its items.Host::recipes_of_typereturnsRecipevalues with parsed ingredients, results and rules for supported vanilla types, and the original JSON for mod-defined types.Recipedoes not define display pages, per-result chances or fluid input/output slots: a viewer interprets its own machine-recipe JSON and draws those details itself.known_recipes,place_recipeandtransfer_recipeprovide recipe-book integration; they do not create a machine-recipe display record.native_spritetakes an optionaltint(0xAARRGGBB) that multiplies every texel, as a fluid's colour does.DrawContext::text(text, at, argb, shadow)takes its colour as vanilla'sGuiGraphicsdoes,0xAARRGGBB(0xFFFFFFFFis opaque white), and draws nothing for a fully transparent one, as vanilla's does.DrawContext::text_width(text)measures whatnative_textdraws, in GUI pixels, to lay text out before drawing it (Font.width). A sprite's own size isgui_sprite(&id)'ssize, read once intexture_atlas_stitched. Diverges from upstream: therenative_texttakes an opaque0xRRGGBB. Slot clicks keep theirSlotClickednumbering: inputs, then results. -
GameLevel::native_send_particleson authoritative servers. It sends supported vanilla particles to nearby players. Block particles require a valid block state; item particles require a validated stack. Trail and vibration remain unsupported. Count zero sends one directional particle. The return value says whether the call was accepted, not whether any player received it. -
Authoritative world lifecycle events through
native_world_lifecycle: open failed, final close and close failed (contract version 2). Migration (WorldPhase::WorldOpenReady→server_started): a mod that matchedWorldOpenReadyinworld_lifecycleimplementsserver_startedinstead, fired at the same moment (after the worlds open, before the tick loop; one call per successful open, with the samededicatedflag asserver_about_to_start); delete the match arm. Nothing else about the phases changed. Diverges from upstream: upstream still has the phase. -
Authoritative server tick events through
native_server_tick: start (before any level ticks, like NeoForge'sServerTickEvent.Pre) and end of each non-paused server tick, withworld_running. A frozen world reports false except during a requested step. -
Server commands and players:
native_register_commands(the commands a mod adds to every player's command tree) andnative_run_mod_command(a player's command on the server), with helpers to answer (Server commands and players). -
A player's items for server mods: counting, taking and giving go through the player's
ENTITY_ITEMShandler. -
Server menus: a vanilla chest menu a server mod opens and fills, whose clicks and closing reach the mod (
native_menu_clicked,native_menu_closed), and trades on them between the players'ENTITY_ITEMShandlers in one transaction (Server menus). -
Item tooltips: an
ItemPart'stooltipadds its item's own lines whereItem.appendHoverTextdoes, after the name and before the components' lines, never for a tooltip the stack hides.Hooks::item_tooltip(NeoForge'sItemTooltipEvent) then gets every item's whole tooltip once it is complete, with the local player, the stack'sTooltipDisplayand itsTooltipFlag(advanced,creative), and may add, change or remove any line. Horizon's creative search reads vanilla's exported tooltip text, so it does not fire with no player while search trees are built. -
Data component tooltip lines:
Registrar::register_tooltip_appender(component, placement, appender)(NeoForge'sRegisterTooltipAppendersEvent) gives one component'sTooltipAppenderaTooltipPlacement:BeforeAll,AfterAll, orBefore(id)andAfter(id)for a component detail Horizon lists (minecraft:lore,minecraft:enchantments...), where the lines go among the other components' lines. The game asks only that appender, only for a stack that carries the component (live, transient ones included) and whosetooltip_displayshows it, and only while its mod is enabled. It gets the stack and aTooltipAppendContext(the local player,advanced,creative, andnestedfor a crossbow's projectile), and adds its lines after the item's own lines and before the durability and advanced lines. The creative search doesn't read these lines, as it doesn'titem_tooltip. A mod that reacts to every item keepsitem_tooltip.Before/Afteraccept only the 23 vanilla anchors listed intooltip_appender.rs, not other registered components. TheregisterAppender(TooltipLocation, ...)overload is not represented. Several appenders may register for one component; all run in registration order at an equal placement. The player is always the local player, including for nested crossbow projectiles where vanilla passes no player; usenestedto distinguish those calls. -
An item's own server behaviour:
ItemPart::use_on(host, event)hears a right click with its item on a block once the block let it through (ItemStack.useOn), andItemPart::use_item(host, event)hears the item's own use, in the air or afteruse_onlet it through (Item.use). Each hears a click once. Claiming either skips the template and swings the hand. These scoped methods have no globalHooksequivalent. -
A block's own item-use behaviour, on the server for every player: attach a
BlockPartand implementuse_item_on(host, event) -> Option<InteractionResult>.event.statekeys the per-state table;event.clickcarries the level, position, hit, hand, stack and player.Somereplaces the template'sBlock.useItemOn:Success/SuccessServerconsume and swing,Consumeconsumes without swinging,Pass/Faillet the item try itsuseOn, andTryWithEmptyHandtries the block'suseWithoutItemon the main hand only.Noneleaves the template in charge. This is the block's behaviour, not NeoForge's separateUseItemOnBlockEventbroadcast interceptor. The earlierright_click_blockevent still decides whether the block is used at all. Dispatch visits only the clicked state's parts. -
Global interaction interception on both sides:
Hooks::use_item_on_block(host, event)is NeoForge'sUseItemOnBlockEvent.event.phaseisUsePhase::Blockjust before the block's item method/Part, orItemAfterBlockjust before the item'suse_onmethod/Part. It fires only after that phase's sneak,use_block/use_item, cooldown and other eligibility gates select it.event.click(BlockClick) supplies the dimension, hit, hand, stack and player. ReturnFlow::Cancelto skip that phase's Part and template, using*event.cancellation_resultas the phase's result (defaultTryWithEmptyHand); a main-hand block-phase TRY can still run the empty method.UsePhase::ItemBeforeBlockfires first, before the item'son_item_use_firstPart and before the block is asked anything (ahead of the sneak gate, unlessright_click_blockdenied the item); a cancel there answers for the block and the item. On the client only this broadcast event fires: a Part's claim comes from the server, which corrects the predicted click as it does for the block Part. -
ItemPart::on_item_use_first(host, event)isIItemExtension.onItemUseFirst, server side, for the held item (event.clickis the sameBlockClick). ReturnSome(result)to end the click: neither the block nor the item'suse_onruns, andSuccessswings the hand.NoneorPassgoes on to the block. It has no global method: NeoForge's bus event for this moment isuse_item_on_block'sItemBeforeBlockphase. -
Hooks::right_click_item(host, event)is NeoForge'sPlayerInteractEvent.RightClickItem, beforeItem.useand the item'suse_itemPart on both sides, after the same hand's target interaction falls through.event.click(ItemClick) contains the dimension, player's feet block position, player, hand and stack.Flow::Cancelskips the Part/template and returns*event.cancellation_result(defaultPass). OnlySuccessends the client's hand loop and predicts a swing; all other canceled results continue to the other hand, includingSuccessServerandConsume. A block success, block FAIL, or entity success prevents item fallback; the server fires this on the separate UseItem packet rather than on UseItemOn. -
A block's empty-hand behaviour:
BlockPart::use_without_item(host, event)replacesBlock.useWithoutItemon the server, on the main hand only, afteruseItemOnreturnsTryWithEmptyHand. The event keeps the original state's key even if the item phase changed the block.Somereplaces the template;Noneruns it, including jukebox ejection, pot wobble and bookshelf removal. Consuming results trigger DEFAULT_BLOCK_USE.Pass,FailandTryWithEmptyHandcontinue to the item; the empty method is never retried. Dispatch visits only the clicked state's parts. -
Breaking and placing a block, on the server for every player:
native_break_blockhears a player break a block once the game allowed it (ServerPlayerGameMode.destroyBlock) and may cancel it, which keeps the block and its drops. Setexp_to_droptoSome(amount)to replace the vanilla XP sample later, after item drops are prepared;Noneretains vanilla sampling. The subsequentblock_dropsevent can edit the resulting experience or cancel the drops, and disabled block drops suppress both items and XP.native_entity_place(EntityPlaceEvent,EntityMultiPlaceEvent) hears a block placed from an item (BlockItem.place), an enderman putting its block down or a snow golem's trail, before it is written, and may cancel it: nothing is placed and the stack is kept. For a player,placed_againstis the state next to the primary placed block opposite the clicked face, even when the clicked block is replaceable; for mobs it is the state below. A bed, door or tall plant arrives as onePlacement::Multi. Silverfish infesting, turtle eggs and falling blocks are not placements yet. -
Block entities for a mod's blocks (
native_block_entity,native_block_entity_ticks,native_block_entity_tick): made when the block is placed, ticked on the server and saved with the chunk.entity.id()reads its type identity, fixed at construction; mutable events give aModBlockEntityViewwhose payload containsnbt,itemsanddata. A mod cannot replace the view or access its private entity handle. -
Item slots for those block entities (
native_block_entity_container): hoppers, comparators and breaking the block use them as they use vanilla's containers, andnative_block_entity_tickalso gets the slots. -
Menus for mod containers: the container names one of vanilla's slot-grid or furnace menus, which opens when the block is used, and
native_block_entity_ticktakes the whole block entity, with the data slots the menu shows. -
A machine reaches neighbouring storage through the block's Parts and capabilities, not a raw mutable block-entity callback.
block_entity.data_slots(host)reads a mod entity's menu data on the server or the client's own copy;set_data_slot(host, slot, value)writes a declared slot on the server under the current transaction, rolls back on abort, and uses the normal menu broadcast to viewers.level.set_blockuses the level's normal update path. -
Screens a mod draws for its blocks' menus:
native_screenlays one out (its height, where each container slot is, where the inventory starts; a slot it leaves out is not in the menu, so shift-clicks skip it) andnative_screen_drawdraws it with the mod's own GUI textures (assets/<namespace>/textures/gui, drawn withnative_sprite). -
Buttons, tooltips and item icons on mod screens:
native_screen_clickandnative_screen_tooltip(the client),native_menu_button(a button press on the server, on the mod block entity) and the helpersnative_item,native_screen_title,LevelRef::block,LevelRef::block_propertyandnative_cursor. A mod inserts items through the block's item capability,level.capability(host, &ITEMS, pos, side). -
menu.click_button(host, button), on a clientMenuRef, presses buttonbuttonof the open menu as a click on one of its buttons does (MultiPlayerGameMode.handleInventoryButtonClick,ServerboundContainerButtonClickPacket); the server's menu decides what it does (clickMenuButton, andmenu_buttonfor a mod's menu). It is for a client mod's own input on a menu's screen wherescreen_clickdoesn't come, such as a key or the wheel (screen_key_pressed_pre,screen_mouse_scrolled_pre). It returnsfalsefor a stale handle and for the inventory menu, which no server opened. -
What a server mod needs to guard land and move players.
native_right_click_blockhears a player's right click on a block before the block's own use (a chest, a door, a lever) and the item's, and may cancel it with NeoForge's answer:Failstops the click, and the player sees the block as it is.host.players().acting_player()returns the acting player handle;host.commands().run_commandruns a command as the server's console (level 4, no output), soexecute in <dimension> run tp <player> <x> <y> <z>moves a player across levels;PlayerRef::action_bartells a player why, even inside their own hook; and the handleshost.players().server_players()lists expose health throughplayer.entity().living(host). -
What a server mod needs to keep the world itself out of guarded land.
native_block_spreadhears a fluid flow or fire spread into a block,native_piston_prea piston's push or pull,native_explosion_detonatethe blocks an explosion is about to break (a mod takes out those it spares),native_fill_bucketa bucket filled or emptied, andnative_attack_entitya player's blow on an entity (an item frame, an armor stand, an animal, a player). Each may cancel, and nothing happens. -
native_level_tick_preandnative_level_tick_post, both-sided observations around each level tick pass. Each receives the dimension ID; neither can cancel the tick. Paused and held passes fire neither hook, and an error skips the post hook. -
native_set_block, a server-sideGamehelper that replaces a state in the current dimension throughLevel.setBlock. It accepts vanilla's neighbour and shape flags and returns whether a block changed; a block with a block entity gets one.UPDATE_CLIENTSsends the update;UPDATE_INVISIBLEsuppresses notification only on the client, as inLevel.setBlock. Hooks holding onlyGameLevelcannot call it yet. -
GameLevel::native_play_soundfor registered vanilla sound events on an authoritative server. It queues the sound for nearby players in the level's dimension, including during a guest's turn. Pass aSOUND_SOURCESindex, position, volume and pitch. The return value says whether the call was queued, not whether a player heard it. The server discards unknown sound events when it dispatches the queue. -
Host::config_files(): the calling mod's own files in the game's sharedconfig/folder besideoptions.txt(besideserver.propertieson a dedicated server): its folderconfig/<mod id>/and the files beside it named<mod id>.<ext>or<mod id>-<rest>.ConfigFilesreads, writes, lists and stats them through&dyn Hoston either side, at most 4 MiB a file; a write replaces the file at once. The service is bound to the mod whose hook or part is running (a mod never names its id), so another mod's path, a symbolic link on the way or a call outside any mod's hook is refused with a typedConfigFileError. A path is aConfigPath, checked once when it is made. A world'sserverconfig/copy is read only throughnative_config_loading(native mods). -
The player-health HUD rows through
native_health_hud: every frame the HUD draws them, the hook hears the layout's inputs (HealthInputs) and the screen's GUI size, and a mod that answers with draws replaces all the rows — armour, hearts, food, air and the heights above the hotbar — for that frame. Nothing answered draws Horizon's own. -
Which hearts the player's health bar draws through
native_player_heart_type(NeoForge'sPlayerHeartTypeEvent): each frame the HUD draws the hearts,kindcomes in as vanilla's pick (poisoned, withered, frozen or normal) and a mod may set another. It runs beforenative_health_hud, whoseinputs.kindcarries the answer; only the heart sprites change. -
Where each boss bar is drawn through
native_customize_boss_event_progress(NeoForge'sCustomizeGuiOverlayEvent.BossEventProgress): once a frame per bar, in the order the server added them,xandycome in as the bar's top-left corner (91 left of the centre; 12, then 19 lower for each bar) andincrementas the spacing to the next (19). The bar and its title are drawn where a mod moved them, and the next bar comes inincrementbelow where this one came in. A cancelled bar is not drawn and its increment still applies. Bars stop once the next would start a third of the way down the screen. With no listener nothing is built per bar. -
Where the in-game chat is drawn through
native_customize_chat_overlay(NeoForge'sCustomizeGuiOverlayEvent.Chat): each frame the chat layer draws,xandycome in as the chat's bottom-left corner (0 and the GUI height minus 40) with the GUI size and whether the chat screen is open, and the chat is drawn shifted to where a mod moved them. Not asked while the chat is hidden (F1 with the chat closed). Only the drawing moves; the input box and clicks on messages stay in vanilla's place, as with NeoForge. -
Right-clicking an entity, on the server for every player:
native_entity_interacthears a right click on an entity (its id and type, the typedHand::MainorHand::Off, and the stack in that hand) wherePlayer.interactOnstarts, once per hand. A mod that claims it swings the hand instead of the entity's and the item's own interaction. -
Left-clicking a block, on the server for every player:
native_left_click_blockhears the start of a dig (the block, the face hit) once the game allowed it, before the block is attacked (a note block plays) or broken in creative. A mod that cancels it keeps the block: on the server it isn't attacked, dug or broken, and the player's client gets it back. The clicking player's own client still predicts the crack until then. -
A player's tick:
player_tick_preandplayer_tick_postmatch NeoForge 26.3's non-cancellablePlayerTickEvent.Pre/Poston both logical sides, including ordinary single player's client window and server-thread players. Match the typedHostSide; the event'splayer: PlayerRefunambiguously names the ticked player (including other client-side players). On the server the helpers read that acting player. Pre precedes sleep/cooldown/body work; Post follows it, including item, arrow and XP pickups. Dead players still receive the pair without enabling dead actions. Players tick under/tick freeze, not while paused. With no listeners, the added per-player cost is only the indexed subscriber probe for each endpoint; listeners on the server pay an acting-guest enter/leave at each endpoint, reusing the already resolved guest index (no second connection lookup). -
Shape updates a mod can answer through
native_update_shape: on an authoritative server, each time a block's state is worked out from a neighbour (BlockBehaviour.updateShape): neighbour shape updates after asetBlock, a block fitted to all its neighbours as a piston lands it or a chunk is post-processed, and the blocks at a feature's edge as it is placed in the live world, such as a tree grown from a sapling (StructureTemplate.updateShapeAtEdge). It gets the dimension, position, state, the neighbour's direction, position and state, before the block computes it.statemay not be in the world yet (a piston landing it), so decide fromstate, not from the block atpos. Answer only for your own blocks and leavenewempty otherwise: a state put there is the block's new state, applied as vanilla applies the result (air breaks the block, with its drops, on a neighbour update; post-processing and a feature's edge write air without drops), and the block's ownupdateShapedoesn't run, nor the ticks it would schedule. The level is read-only, as vanilla'sLevelReaderis there; the hook schedules ticks with itsticksthrough the level'snative_schedule_block_tick(ticks, pos, block, delay)andnative_schedule_fluid_tick(ticks, pos, fluid, delay)(vanilla'sScheduledTickAccess.scheduleTick; false for an unknown name or a position that isn't loaded). It runs often, at least six times persetBlockthat updates shapes (more for redstone wire's diagonals and knock-on updates): keep it short. A LAN guest shows vanilla's shape for a moment, until the server's update arrives. Chunk generation fires none. -
Random ticks a mod can handle through
native_random_tick: on an authoritative server, each blockServerLevel.tickChunkrandom-ticks (BlockState.randomTick), with its dimension, position and state, before the block handles it. Only blocks that tick randomly fire it (a mod block as its vanilla template block does), once per draw that lands on one: it runs often, so keep it short. Bone meal's random ticks (StemBlock.performBonemealcallsrandomTickitself) don't fire it. The hook may change the block with the level'snative_set_block_property(ticks, pos, key, value, flags)and sethandledto skip the block's own random tick, so a mod crop grows its own way. Withouthandled, the block then takes its own random tick as it is now (if it still ticks randomly), and lava's fluid random tick runs either way. It needs the level mutably, which onlynative_random_tickandnative_tick_blockgive. It isGame::native_set_block_propertywithLevel.setBlock's flags (native_hooks::flags): a vanilla crop grows withflags::UPDATE_CLIENTS(neighbours' shapes update, so an observer fires, but no neighbour getsneighborChangedand comparators aren't told), a furnace sets itslitwithflags::UPDATE_ALL(both). Horizon sends updates whenUPDATE_CLIENTS(2) is set;UPDATE_INVISIBLE(4) suppresses notification only on the client, as inLevel.setBlock.UPDATE_IMMEDIATE(8) has no effect yet. It writes at any loaded position, returns false off an authoritative server (theGameone too: a window that isn't a server), for a property or value the block doesn't have, a position that isn't loaded, or a property that already has the value (nothing is written: vanilla'sLevel.setBlockreturns false for an unchanged state, asnative_set_blockdoes), and true when the block changed. Elsewhere,Game::native_set_block_property_with(pos, key, value, flags)does the same, as a brewing stand's tick sets its bottles withflags::UPDATE_CLIENTS. -
Scheduled block ticks a mod can handle through
native_tick_block: on an authoritative server, each due scheduled tick of a block that is still there (ServerLevel.tickBlock→BlockState.tick), with its dimension, position and state, before the block handles it. The hook may change the block with the level'snative_set_block_propertyand the flags the block's own tick would write with (an observer's tick usesflags::UPDATE_CLIENTS) and sethandledto skip the block's own tick. Withouthandled, the block then takes its own tick as it is now, if it is still the tick's block. -
The commands a mod owns on the client, as NeoForge's
RegisterClientCommandsEvent:register_client_commandsis asked for aCommandper usage (x <mode:word>, the same usage form asregister_commands), so the chat box colours, completes and lists them, arguments included, like the game's own, andcommandruns them. A name the game already has keeps that command; a nonzerolevelhas no meaning on the client and the command is left out with a log line. It replacescommands, which gave bare names: a mod that implemented it pushesCommand::new("name <args:text>")instead. Asked at connect, never per frame. -
The keys a mod registers on Options > Controls, as NeoForge's
RegisterKeyMappingsEvent: callRegistrar::register_key_mapping(KeyRegistration::new("zoom", "key.keyboard.z").label("key.mymod.zoom"))fromHooks::register. Ownership comes from the registrar; invalid or duplicate local ids refuse startup..category(...)takes a translation key,.modifiers(...)sets shift/control/alt, and.context(...)takesKeyConflictContext::{Universal, InGame, Gui}. Only client startup collects the retained declarations for Controls; dedicated and integrated servers do not. One editor persists keys and modifiers inhorizon-mods.txt. Conflicts compare vanilla and mod bindings, context and modifiers, excluding unbound and both-default pairs.host.mod_key(mod_id, id)returns the saved or declared(key, ClientModifiers), orNonefor an unregistered id. This registration does not listen for presses. Diverges from upstream: the oldmodshook,NativeMod.keysand the Options > Mods key editor are removed, with no compatibility route. -
The
commandhook runs a client command: the local player's typed or clicked chat command, without its slash, before the server and the game's own commands see it; setranwhen the mod ran it. Client-side, with theClientHost. Contract version2: a mod written for1was given the side-typed host, and was also asked on a server for commands run there (command blocks, LAN guests, the console, the integrated server thread); now only the client asks, and only for its own player's commands. -
Recipes of any type (
RecipeManager.getAllRecipesFor), vanilla's (minecraft:smelting,minecraft:campfire_cooking) and a mod's own (mymod:crusher, for a machine or an altar) through one read:host.recipes_of_type("minecraft:smelting")gives each recipe in force, in file order, as aRecipe: itsid,kind,group, typedingredientsandresults(for shaped, shapeless and cooking recipes, as the game parsed them; a recipe the game cannot parse is not in force and not listed),cooking_timeandexperiencefor the cooking types, and its file'sjson, which is all a mod's own type has. The recipes are those ofdata/<namespace>/recipe/in vanilla, content mods (.hmod) and the world's data packs, with data packs merged and load conditions held, so a pack's override or removal shows; a type nobody uses gives nothing.Host::recipe_ingredientandHost::recipe_resultread the usual fields as JSON text, tags included (a bare id is read asminecraft:; items the game does not know are left out, and the helper returnsOk(None)if none is left). Invalid JSON returnsRecipeJsonError::InvalidJsonrather than panicking. The recipes are the server's table: on the server thread, a dedicated server or a LAN host; a client, the single-player window included, sees vanilla's and the content mods' recipes, not the world's data packs', unless a mod had the server send it the type (native_datapack_sync). All three give nothing outside a world. The game crafts the recipes of its own types itself, and shows none of a mod's own in the recipe book;/reloadreads a data pack's again, so look them up again when you need them. The lookup borrows the table, with no copy. -
mobs for server mods: spawn one, read the mobs of a level in place, and move, hurt or remove one by its id.
-
Redstone from a mod's block, on authoritative servers.
native_block_signalmay change the signal a block gives on a face, weak or direct, andnative_is_signal_sourcelets redstone wire connect to it. Both are asked very often, so check the block first and answer from the mod's own state. When the output changes, and once the block is placed (a direct signal past a conductor isn't read before),level.update_neighbors(host, pos, state, NeighbourUpdate::Neighbours)makes the blocks around it read it again (Level.updateNeighborsAt; a block that powers what it is attached to, as a lever does, also updates there). A diode's front takeslevel.neighbor_changed(host, front, state)thenNeighbourUpdate::NeighboursExcept(facing)(DiodeBlock.updateNeighborsInFront). Both act on the level in place and are refused elsewhere. -
Item behaviour, on the server for every player:
native_hurt_enemyhears a player's hit land on a mob with the held stack,native_inventory_tickruns each tick, on theItemParts of every stack a player carries (armour and off hand included), and the stack is the placeItemPlace::PlayerSlot { player, slot }, read and written as any item place, andnative_on_equiphears an equipment slot take a different item. -
mod messages between a mod's server and client halves, and hearing players and mobs on the server for every player:
native_player_died,native_mob_spawned,native_mob_killed(a player's melee blow, a sweep or a spear thrust too),native_item_toss(ItemTossEvent: a throw; not the items scattered when a player dies;canceledmakes no item entity and the items are lost) andnative_item_picked_up(canceledleaves the stack on the ground). They name a player by UUID. -
Server data reload listeners read a mod's pack resources with datapack overrides, once per reload.
-
Client asset reload listeners read a mod's pack resources with resource-pack overrides, once per reload.
-
The item entities near a block, for machines that pick up items as a hopper does:
native_item_entities(dimension, min, max)lists those whose box meetsmin..max(vanilla'sLevel.getEntitiesOfClass(ItemEntity.class, box)), each with its entity ID, position, stack and pickup delay, andItemEntityRef::set_stackchanges one's nonempty stack; discarding an entity is a separate operation. The query acts on the level in place. -
LevelRef::with_random(host, |random| ...), the server level's own random (vanilla'sLevel.random, NeoForge mods'level.getRandom()): a mod draws from the stream vanilla's explosions and block drops use, so its draws shift theirs as a Java mod's would. The closure draws on the level's stream and the level keeps it after (random(host)is a copy of it,set_random(host, random)stores one back), also for a level parked while the server ticks another. It isNoneon a client of a server, for a stale handle and for a level that isn't open: the Nether and the End open when someone first goes there and close after 300 ticks with nobody in them. Inside a restricted level hook,with_randomon theLevelRandomHostdraws from the same stream. -
Server lifecycle hooks run once per start, on both the single-player server thread and a dedicated server.
server_about_to_start(dedicated)runs before worlds load (ServerAboutToStartEvent), immediately followed byserver_starting(dedicated)(ServerStartingEvent), before the server accepts play.server_started(dedicated)keeps its existing firing point: every level loaded and the first tick not yet run (ServerStartedEvent). On every exit after startup began, including a failed open, failed tick, or caught panic,server_stopping(dedicated)(ServerStoppingEvent) runs before player cleanup and the final world save attempt.server_stopped(dedicated)(ServerStoppedEvent) runs last, after network teardown, flushing saves and releasing storage, even when the save failed or a shutdown listener panicked. An early callback panic (includingconfig_unloading) still reaches the final adopted-world save attempt and full server/network teardown, not just unpublishing or flushing queued writes. The first failure is retained if cleanup also fails. These are server-side, mutableServerHostnotifications, not cancellable. A failed open never overwrites a world the server did not successfully adopt: the session lock is taken before the prepared level is installed, and only that installation allows the final save.WorldOpenFailedorWorldCloseFailedstill reports the failure;WorldCloseFinalis only emitted after a successful orderly close. No tick-time cost: these hooks dispatch only at server startup and shutdown. -
Mod block entities are sent to clients. Only declared types receive wire IDs after vanilla's, and several valid blocks can share a type. Server and client use its declared identity;
native_block_entitysupplies only initial data.native_block_entity_update_taggives what clients get of one (BlockEntity.getUpdateTag, read from the block entity alone): it is asked when a chunk holding it is sent and when its block changes, and the client merges it into the copy'snbt. A saved chunk sent past the view distance asks it on its own thread, so read onlyentity. Keep it to what clients show (a machine running, its facing, a tank's level), not a menu's progress: menus have their own data slots. -
One-shot fields ride that tag.
entity.set_client_update_fields(fields)on aModBlockEntityViewqueues them in the runtime under the entity's identity, for native and wasm mods alike; they are not part of the plain-data entity, so saves, clones and comparisons never see them and its revision stays. Once a block change (block_entity_sync) reaches viewers, the game builds one tag, puts the fields overnative_block_entity_update_tag's and sends it to them all. A change nobody sees, a chunk send and a single player's resend leave them waiting; removal or unload drops them. -
native_affect_neighbors_after_removalis a mod block'sBlock.affectNeighborsAfterRemoval: the block was removed frompos(moved: a piston pushed it) withUPDATE_NEIGHBORSor the piston flag. There is no bus event for it, so it is aBlockPartmethod only. The block's own class has already run its updates, as an override'ssupercall; the part then pushes ontoupdateswhat it wants, in order:NeighbourUpdate::OutputSignal(updateNeighbourForOutputSignal),Neighbours(updateNeighborsAt) andNeighboursExcept(direction)(updateNeighborsAtExceptFromFacing), and the level runs each at once with vanilla's own functions. It runs inside the level's write with the level host, so it reads but cannot write blocks or spawn entities; a mod that needs either reports the gap on #44. The cost with no part on the block is onehas_part_hookread. -
native_pre_remove_side_effectsis a mod block entity'sBlockEntity.preRemoveSideEffects: its block is being replaced or broken with side effects (not under flag 256). Its part puts the stacks it held indrops, and they scatter asContainers.dropItemStackscatters them, after the block entity's own container slots. It runs inside the level's write with the level host, likeneighbor_changed; the block entity is still in place, so the part reads its attachments withTYPE.get(host, Holder::BlockEntity(pos)), the level host being their store. A part that returns nothing draws nothing. A block entity that removes its own block during itsblock_entity_tickormenu_buttonis out of the level for that call, so its part isn't asked: its attachments go with it, and only its container slots come back out. -
native_visuals: a world-frame hook that appends block-rim and colored-light requests (BlockRim::new(pos, argb, width),ColoredLight::new(at, rgb, radius, intensity)). Rims trace the client's existing block outline edges, depth-tested against terrain. Lights add camera-local RGB illumination to the normal terrain/entity shaders, not gameplay light. Only the first eight rims and eight lights are examined; positions must be within 32 blocks of the player, values must be finite and bounded, and source chunks must be loaded. At most 256 rim edges and 16 colored lights are rendered. Requests are frame-local, so disabling a native mod or leaving the world clears them without a retained state. Helpersnative_lit_block_nameandnative_flame_particleread loaded blocks and emit a nearby vanilla flame particle; native mods must rate-limit emission. It also addsnative_emissive: a world-frame hook for at most 32 pairs of already-loaded terrain sprites. Transparent pixels in an overlay leave its base unchanged; opaque pixels are drawn full-bright without changing gameplay light or the surrounding rock. The source pack must supply the overlay texture underassets/<namespace>/textures/emissive/. When no mod requests overlays, the terrain shader skips the lookup. When a native mod bundles a Horizon shader pack, naming its foldernative-<mod-id>makes the Mods On/Off control pause that pack too (without changing the player's saved shader selection); other packs are unaffected.
Custom ingredient metadata
ingredient and ingredient_test carry an API Compound. Recipe JSON is
converted at load by the shared JsonOps-to-NbtOps boundary; mod code matches
Tag variants rather than JSON fields. Nested null refuses the recipe as an
unclaimed kind does; numeric and heterogeneous arrays remain lists. Matching
borrows the retained compound and never converts metadata on the tick.
native_open_chat(text) opens the chat screen with text typed in for the player to finish and send, as T and / open it; nothing is sent; an error with no player in a world or while another screen is open. Bundles need "hooks": @API:open_chat@.
native_simulate_player_move(uuid, input) plays one tick of a server player's movement from a bot's keys (forward, strafe, jump, sneak, sprint, yaw, pitch, the last velocity) with the client's own movement code, as LocalPlayer.aiStep and Player.travel do, and returns the position, velocity and ground it reaches without moving the player; native_collision_boxes(dimension, aabb) lists the block collision boxes in a box, as Level.getBlockCollisions(null, aabb). Bundles need "hooks": @API:player_bots@.
Client brand
PlayerRef::client_brand(host) answers which client a player joined with, from the brand string
the connection recorded (ServerPlayer.clientBrand). Horizon's own clients say horizon, which
is what the game itself checks before it sends them Horizon-only packets, so a mod that does the
same asks here rather than guessing from the name.
Selected slot, dropping a stack and closing a menu
PlayerRef::set_selected_slot(host, slot) makes slot (0 to 8) the hotbar slot a player holds
and tells their client with SetHeldSlot; a new slot ends a main-hand use in progress, as the
client's own switch does, and player_switch_hotbar_slot_pre is not asked, since it is for the
player's own input; it reaches the client of a guest acting in their own turn too.
PlayerRef::drop_stack(host, stack, thrown_by_player) is Player.drop(stack, false, thrown_by_player): the stack is thrown from the eyes as an item entity and item_toss may
cancel it. It takes nothing from an inventory, so take the stack out through the item places
first; that is what separates it from giving a stack that falls back to a drop when the
inventory is full. It returns Ok(None) once the item is in the world, and otherwise hands the
stack back untouched as Ok(Some(stack)) (empty, a stale or dead player, a vetoed toss) for the caller to put back,
so a refused drop never loses items. As with other checked stack writes, a failure to convert
the API-owned stack returns StackWriteError before any game operation. API stacks are nonempty;
the engine empty-stack refusal remains internal.
MenuRef::close_container(host) closes a player's open menu on a server as
ServerPlayer.closeContainer
does, through the one path a disconnect and a stillValid failure use: the container's lid, a
vehicle's or trade's stand-in, the crafting, station and mount menus, the carried stack back to
the inventory, player_container_close once, and the client's window closed. The player's own
inventory menu is never closed. A client closes its own screen with MenuRef::close.
Recipe book view
PlayerRef::discovered_recipes(host) lists every recipe the player's book knows, the whole set
has_discovered_recipe asks about one at a time. It reads a guest's book from outside their turn
as it reads the host's, and changes only through award_recipes and reset_recipes.
native_refresh_commands() (Host::refresh_commands() for trait mods) asks every mod's native_register_commands again at the start of the next server tick, rebuilds the commands players may run, and sends every player on the server their tree again after native_player_commands_sent, as vanilla's PlayerList.sendPlayerPermissionLevel (Commands.sendCommands) does after /reload. Nothing happens where no world is open to players. Bundles need "hooks": @API:refresh_commands@.
On the fork this method is host.commands().refresh_commands(), on the server-side
Commands service. It rebuilds the existing typed command dispatcher as well as
the client trees. It is not a second Host method or a registration hook.
sound_play intercepts actual client sound starts and live parameter updates. SoundPlayback identifies the instance, event, source, position, relative flag and whether it is starting. Edit base volume/pitch transiently or cancel before subtitles/backend writes; invalid edits cancel. No Host/world borrow or reverb/DSP is supplied. Runs on the game thread; use existing client hooks for world/acoustics state. Bundles need "hooks": @API:sound_play@.
native_world_border(dimension), the bounds of a level's world border as the server enforces it (one border for every level in Horizon today). Bundles need "hooks": @API:world_border@.
native_world_seed(), the world's seed as /seed prints it (Bukkit's World.getSeed), the one every level generates from; None off a server. Bundles need "hooks": @API:world_seed@.
Villager farming
Farmers recognize mod blocks made from farmland and CropBlock templates, including declarations from
native_blocks. A crop block's generated item is its seed: add that item to
minecraft:villager_plantable_seeds to let farmers plant its default block state from their inventory.
Untagged items and tagged items without a block are skipped. Crop maturity follows the template's class:
beetroots mature at age 3, ordinary crops at 7; torchflower crops never reach their maximum age 2 while
still crops. Pitcher crops are not CropBlocks and are not harvested by this behavior. Farming still
requires a farmer at work and mob_griefing enabled.
World views
A client mod can have the world drawn again from cameras of its own, into targets of its own: a camera monitor, a
second camera's preview beside the player's view, stills for a film strip. As each frame is built and drawn (not on
the title screen's panorama), world_views asks for them, more than once a frame, so keep it cheap: push a
WorldView::new(id, camera, yaw, pitch, fov_degrees, size) (with_roll(degrees) turns it about the view
direction) for each one wanted this frame. Up to MAX_WORLD_VIEWS (4) draw, each at most MAX_WORLD_VIEW_SIDE
(4096) pixels a side, or the device's largest texture side when that is smaller; one with a value out of range or an id already asked for is left out. Push the same views
each time within a frame.
Each view is drawn after the frame's world with the frame's terrain (solid and translucent), sky colour, fog and
entities (block entities too), from its own camera and with its own frustum; then world_view_drawn hands it back
as a WorldViewDrawn: its id, color (color_format, the world's light as WorldDrawn::color holds it, before
the tone curve, without the hand, the HUD or the frame's post passes), depth (Depth32Float, reversed), view_proj
(the world relative to the view's camera, to its clip space), and the device, queue and the frame's encoder. Draw
your own geometry into it there, sample it (both textures can be bound) or copy it out: copy_texture_to_buffer
into a buffer of your own and map that after the frame, for a still. The target stays the view's while each frame
asks for it, so a texture view of it can be kept and shown, and is drawn over every frame; one no frame asks for any
more is freed. Views draw before world_drawn, which can put them into the frame's picture.
A view sees what lies in its own frustum within the render distance: the sections the frame draws and the entities
it was given are the player camera's and the views' together. While any view is asked for, nothing is left out by
the player's camera alone (its frustum for entities, the caves, occlusion, faces turned away) and GPU culling is
off, so the frame's own picture stays the same but costs more; the views' sorting of translucent terrain is the
player's camera's. A view draws no sun, moon, stars, clouds, particles, weather, block outline or hand, and none of
a shader pack's passes. A frame without views costs nothing more: while no mod listens to world_views it is not
called.
python3 tools/run_world_views.py <dir> (throwaway homes) draws views through the renderer offscreen and checks
that the frame's picture is unchanged; --bench times a frame with and without one.
Discoverable bundled packs
Hooks::add_pack_finders receives a typed HostSide and an
AddPackFindersEvent. Use event.packs.pack_type() to distinguish
PackType::ClientResources from PackType::ServerData. Register a
PackRegistration with event.packs.add_pack_finders(...); registrations for
the other repository type are ignored. This hook contributes whole packs, while
resource-file hooks and reload listeners retain their separate roles.
A location such as example:assets/example/resourcepacks/high_contrast names a
loaded mod and a directory inside its package, containing pack.mcmeta and the
usual assets/ or data/ tree. Folder packages and .hmod archives use the same
logical paths, without extraction. Invalid locations, missing metadata and
duplicate repository ids return PackFinderError without adding a partial pack.
The repository id is mod/<location>. Packs are optional by default: resource
packs appear in Resource Packs, and data packs appear in the world's Data Packs
screen and /datapack repository. Selecting a resource pack applies it through
client reload; data-pack selection applies through the world's data reload.
Discovery runs again for reload, so replacing an archive opens its new contents.
Use translated Text for the registration's title and pack metadata description.
always_active makes a pack required and prevents deselection. position
chooses its default insertion priority (Top or Bottom); later resource
layers win. Required data packs also enter a new world's initial selection when
the player does not open the Data Packs screen. PackSource::Feature exposes
packs with declared feature flags in Experiments. Their flags must be enabled
for the world before /datapack enable accepts them, and feature packs that
request flags cannot be disabled through that command.
Section geometry
Section geometry (NeoForge's AddSectionGeometryEvent) is quads a mod adds to a chunk section's own mesh, for
geometry that belongs to the terrain but no block model draws: a mod's wires between blocks, markings on the ground.
Hooks::add_section_geometry registers the mod's SectionGeometry once at start, and each time a section meshes
the mesher asks its add(origin, context) to add its quads, after the section's blocks:
struct Marker;
impl SectionGeometry for Marker {
fn add(&self, origin: [i32; 3], context: &mut dyn SectionGeometryContext) {
if origin != [0, 64, 0] {
return;
}
let light = [context.level().brightness(LightLayer::Sky, [8, 65, 8]) as u8, 0];
context.add("minecraft:block/gold_block", SectionQuad {
positions: [[7.0, 1.01, 7.0], [7.0, 1.01, 9.0], [9.0, 1.01, 9.0], [9.0, 1.01, 7.0]],
uvs: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
color: 0xFFFF_FFFF,
light,
translucent: false,
});
}
}
fn add_section_geometry(&self, _context: &RegistrationContext, event: &mut AddSectionGeometryEvent<'_>) {
event.renderers.register(Arc::new(Marker));
}
Positions are in blocks from the section's lowest corner, each 0 to 16, UVs within the sprite (0 to 1), and the
sprite a block atlas sprite's name. What a renderer adds to a section is taken whole or refused whole, with one log
line: a corner or UV out of range or not a number, a sprite the atlas lacks, or a quad past a budget refuses all of
it, and context.add answers false from then on; the section keeps its own blocks. A section's blocks and every
renderer's quads share one budget, 262144 quads and 32 MiB of mesh data (context.remaining() says what is
left), and the sections meshing at once share 128 MiB of added quads not yet in their meshes. A renderer that panics
is asked no more. context.level() reads the section's chunk and its eight neighbours, as a block tint source's
level does. The renderer runs on the meshing workers, so it keeps no mutable state and gets no Host; it is asked for every
section the client meshes, also one that holds only air, and returns quickly when it has nothing there. Its quads
are shaded only by the colour and light it gives them. A section meshes again when its blocks change, when the
resources reload, and when a mod calls ClientHost::refresh_render_visibility and the hidden blocks change.
Tree ground
alter_ground(context, event): NeoForge'sAlterGroundEvent, fired whereAlterGroundDecorator.placehas found the positions it places around and before it places any ground.event.providerstarts asGroundProvider::Decorator, the decorator's own provider; a mod sets it toGroundProvider::Modwith its ownGroundStateProvider. The decorator then asks that provider once per position it tries, top down in each column (state_at(level, pos)): a state is placed there and the column stops, None tries the next position down. A provider reads the region throughlevel.block_state(pos), draws from the decorator's own random throughlevel.random(), and asks the provider it wrapped withGroundProvider::state_at(level.decorator_state(pos)for the decorator's own). With no listener, or withproviderleft asDecorator, the decorator runs exactly as vanilla's, blocks and draws alike; a provider that only wraps the decorator's own keeps them too.- Where it runs. On the worldgen worker threads while chunks decorate, and on the server thread when a sapling
grows into a tree with this decorator. There is no
Host:contextis aWorldgenHost, the server's registries, settings and mod list as they were at its last resource load (start or/reload). A provider is shared by the workers, so it keeps no mutable state; the same seed must give the same ground. - Answers. A state the game's registry doesn't have stops the tree feature, as an invalid state in a data pack does. A wasm mod's hook and provider calls run one at a time for the mod's group: a wasm listener serialises the workers that decorate trees with this decorator.
- Cost. With no listener the decorator pays one subscription check per decorated tree and allocates nothing. With a listener, one event per tree; a mod's provider is asked per position instead of the decorator's.
Block drops
block_drops(level, dimension, pos, state, breaker, tool, drops, experience, cancel)(NeoForge'sBlockDropsEvent):Block.dropResourcesbuilt the item entities for a broken block and nothing has spawned yet. Editdrops(ItemDrop: stack, position, velocity, pickup delay) orexperience, or setcancelfor no drops, no experience and nospawnAfterBreakeffects (an infested block's silverfish; a bee nest's bees areplayerDestroy's and still come out).breakeris the player's UUID, none for a level-side break;toolis their stack. It fires on the player's break, mob block breaks that drop loot, explosions and every level-sideBlock.dropResources. Positions and motions are drawn before the event, in vanilla's order, listener or not, and the list is the one that spawns, so a mod that leaves it alone changes nothing. It runs inside a level write, so it gets the level host: reads and the level's random, no writes. Player, mob and explosion block loot still runsloot_dropsfirst; a level-side break is not askedloot_drops. Not fired for dispensers or other loot-table drops. Explosions start with zero experience, but a listener may award it. Their events run after the blast removes the blocks and merges loot, before items spawn. Each block owns only its contribution to a merged entity, with that entity's prepared position and velocity: cancel removes only that share. Unchanged shares rejoin the original merged entity; edited or added drops spawn exactly as returned. Empty-loot blocks still fire; TNT that primes instead does not. A no-op listener preserves both merging and level-random draws. Tick-time effect: explosions add alistenedbranch per block; contribution tracking allocates only with listeners. Mob breaks use the same item construction and XP sampling as other block breaks. Level-side breaks and explosion loot fire it too. Diverges from upstream:block_expis removed intoexperience(its catalog row named no NeoForge event).
Add blocks to block entity types
Hooks::block_entity_type_add_blocks runs once after content declarations and before
worlds load, with a RegistrationContext. Retain block and type keys from register.
Call event.modify(kind, &[block]), or use event.modify_matching(|kind| ..., &[block])
to extend every matching type. The predicate sees all registered type keys.
Validity is many-to-many. An addition allows an existing entity of that type to remain
on the block; it does not select the block's factory. A declared native type still
owns creation, and a content block otherwise inherits its original vanilla template.
A vault template associated with minecraft:vault creates, ticks and saves a vanilla
vault, without declaring another entity type. Signs retain their template defaults.
As in NeoForge's public BlockEntityTypeAddBlocksEvent javadoc, a block must inherit
the common superclass of the targeted type's original valid blocks. Invalid keys or
classes fail startup, duplicate additions are idempotent, and a resource reload cannot
change the frozen validity sets or wire IDs. No listener means no added memberships.
Block jump and speed factors
- Block Part methods
block_jump_factor(context, event)andblock_speed_factor(context, event): editevent.factor, the factor of the block stateevent.statethe part is attached to (NeoForge'sBlock.getJumpFactor()andBlock.getSpeedFactor(); vanilla's values are 0.5 jump and 0.4 speed for honey, 0.4 speed for soul sand, 1.0 elsewhere) where an entity reads it.factorstarts at the block's own value. The edit is made on each read of that block, before the entity combines the blocks as vanilla does: the jump factor of the block at the feet unless it is 1.0, then the block below; the speed factor of the block at the feet unless that is water, a bubble column or not 1.0, then the block below. So a part that gives its block 0.5 slows an entity on it just as honey does, and one that gives 1.0 to honey lets the block below decide. Read for the player, every mob's ground and jump path (the rabbit's hop included), boats and minecarts where vanilla reads the speed factor (Entity.move's last step). They fire on both sides and on the client's replayed ticks, so a mod registers on both and keeps each listener a pure function of its inputs.contextis the loop context of the side that ticks the entity, as for the motion value hooks above. The player'smovement_efficiencystill applies after the part's factor. There is no global method: NeoForge has no bus event for these, so a mod reacting to its own blocks attaches a part and one reacting to every block listens toliving_jump_powerandliving_speed. With no part attached to the state, the owning game checks the hook subscription and prepared API block key before any event conversion; with none installed the movement result is bit-identical to vanilla's. Beyond NeoForge (no event); Diverges from upstream: new hooks.
Block screen effect drawing
Hooks::block_screen_effect_draw is submit of NeoForge 26.3's CustomBlockScreenEffectRenderer, set through custom_draw in extract_block_screen_effect, and replace_vanilla is what submit returns: true prevents vanilla rendering. It runs in the frame's ScreenEffectRenderer step, after every overlay's extract event, in vanilla's order: block, water, fire. What it draws for an overlay goes right before that overlay and after the overlays before it, and vanilla's camera overlays (vignette, pumpkin, portal, freezing) go over all of them.
draw is a CameraDrawContext, not the HUD's DrawContext: vanilla's screen effects are camera-space quads under the first-person hand's projection (GameRenderer.render3dHud), in the pass after the level and the hand and before the GUI. Vanilla's own overlay covers the view with corners at (±1, ±1, -0.5). submit's PoseStack starts as the identity there, so a corner given to draw is what a corner under that fresh pose would be: there is no pose stack, and a mod moves its corners itself. submit's sprites are the names CameraDrawContext::sprite takes. Its player and camera render states are ClientHost's player and camera reads. Nothing a mod draws outlives the frame, and no GPU handle is exposed.
With no custom_draw set the hook is never asked, so a mod that only listens here draws nothing.
Client attack targeting
client_attack_target runs with a ClientHost from the remote client's start-attack input path after interaction cancellation and before ordinary melee dispatch. Spear jabs bypass it. Cancellation suppresses attacks; an explicit target batch is deduplicated in first-occurrence order and each handle is checked for lifetime, reach, held-item range and occlusion. The server remains authoritative.
Decoded client packets
Hooks::client_packet receives &mut dyn ClientHost and a read-only
ClientPacketEvent before the window handles a decoded server play packet.
The read-only event.packet is a mod_api::ClientPacket: id identifies the
packet type (for example, minecraft:custom_payload); custom_payload contains
the channel and raw bytes for a custom payload, or None otherwise. An id
outside the protocol's packet table is reported as horizon:unknown.
Other decoded fields are not exposed. This one plain-data contract works
for native and wasm mods, with a generated codec.
The hook cannot cancel or replace a packet. It also observes chunk packets handled by the batching path, once each. Configuration packets and the network thread's own handling (including keep-alives and resource pack messages) do not fire it.
Hooks::client_packet_send is the other direction: a ClientPacketSendEvent with the same
ClientPacket view for each play packet the window's game is about to send (Connection.send),
in order, including the selected slot sent before an attack or a use. Returning Flow::Cancel drops
the packet, and the game goes on as if it had been sent. Play-phase pongs pass through this hook.
Packets the network thread sends itself (keep-alives, configuration pongs, cookies, configuration), the phase-dependent answers (resource pack status,
a dialog's custom click action) and far terrain requests do not fire it. With no listener the
send path checks one flag and builds nothing.
Per-stack crafting remainders
crafting_remainder is an item Part listen query on the authoritative server,
with a read-only Host. Its event starts with the item's data remainder; changing
it to None leaves nothing. Grid consumption (including repeated quick moves),
the crafter, furnace fuel, and brewing fuel/ingredients use the same stack-aware resolver. Book and banner
recipe copies remain recipe-owned. Host::item_crafting_remainder reads data only.
The resolver checks the prepared item subscription table before converting or
cloning stack components. Invalid returned stacks are refused at ItemBoundary,
retaining the initial data remainder; native panics retain normal Part attribution.
Custom spawners
modify_custom_spawners (NeoForge's ModifyCustomSpawnersEvent) fires once per level, the first time the level ticks its custom spawners, with the level's CustomSpawners list: vanilla's five (WanderingTrader, Patrol, Cat, VillageSiege, Phantom) in the order Horizon ticks them. remove takes an entry out so it never ticks (its random draws go with it); add appends a mod's own spawner by a SpawnerKey. With no listener, or one that leaves the list alone, nothing changes: the same spawners tick in the same order.
An object a guest implements cannot cross to a wasm mod, so a mod's spawner is a key it owns and the custom_spawner_tick(dimension, key, spawn_enemies) hook stands for CustomSpawner.tick: it runs once per tick for each key in the list, in list order, while the spawn_mobs rule is on, and the mod that owns the key spawns through its host. Every mod gets the call and the others ignore it. A level with no mod spawner pays one list walk per tick and no hook call. Beyond NeoForge: a key plus a tick hook in place of an object.
Horizon ticks vanilla's spawners where it always has: the trader, patrol, cat and siege spawners in the custom spawner block (vanilla's own order differs, and moving it is a separate parity change) and the phantom spawner with the stats, after the mobs. A mod's spawners tick after them in list order, so the position of the Phantom entry in the list does not move it. Listeners run on replayed ticks, so they are pure functions of their inputs.
Effect particles
native_effect_particle_modification (NeoForge's EffectParticleModificationEvent) is asked once for each active
effect of a living entity, hidden ones included, and changes the swirl the server sends for it (DATA_EFFECT_PARTICLES):
the mobs and the players every client is sent. A listener replaces the effect's particle, sets it to None for the
original, or flips visible, which can show a hidden effect's swirl. A named particle goes only to Horizon clients;
vanilla clients (whose brand isn't Horizon's) get the list without it. DATA_EFFECT_AMBIENCE_ID is decided from the
result, as areAllEffectsAmbient: only effects visible after the mods count. As in vanilla it is asked when the
entity's effects change, not every tick, and the answers are sent until the next change; with no listener nothing is
kept and vanilla's list goes out. The window's own player draws its swirls from its own effects.
Enchanted block loot
enchanted_block_loot(level, dimension, pos, state, tool, enchantment, level, entity)(NeoForge'sEnchantedBlockLootEvent): a block's loot table reads the level ofenchantmentontool, atApplyBonusCount.runorBonusLevelTableCondition.test. Changeleveland that one count or condition uses it: Fortune-style mods change it per block, breaker or dimension, without touching the stack.levelstarts afterget_enchantment_level.toolis none for an empty hand or a toolless break, which is still asked at level 0.entity(LootContextParams.THIS_ENTITY) is the player mining, the breaking mob, the blast's direct source (primed TNT, TNT cart, exploding mob, fireball, wither skull, end crystal), the enderman dropping its carried block, the source of/loot ... mine, the projectile breaking a chorus flower or a decorated pot, the villager harvesting, the ravager in a crop, or none (a neighbour update, a piston, water, a trident through a speleothem, whosedestroyBlocknames nobody; also a boat through a lily pad, though vanilla names it). Au32level can't be negative, assetEnchantmentLevelrefuses; above 1024 it counts as 1024, so a mod's value can't size a loop. A table reaches it once per read, in the roll's order, so a break can ask several times or never. Only block tables ask: chests, fishing, entity tables andblock_interacttables do not. It runs in the roll with the read-only level host, so a listener can't move the table's random; the level random and the sequences are the roll's until it ends. The host can't readentity: a break can come from inside a level write, where the game is split up and there is noHost, sogetEntity's entity is a handle here, to keep or compare. Authoritative servers only. Tick-time effect: onelistenedtest per roll with no listener; with one, each roll builds the level host.
Extend POI types
extend_poi_types runs once the block registry is resolved, before any world's point of
interest tables are built, and again when resources reload. It collects a PoiTypeExtensions
registrar: metadata() lends the resolved block metadata that StatePredicate::block needs,
and extend(poi_type, states) adds the states the predicate selects to the vanilla type named
by poi_type (minecraft:home, minecraft:armorer, minecraft:beehive, ...). A predicate
with no constraints adds all of the block's states; constraints narrow it (part=head).
Added states are saved, located and found by villagers exactly as vanilla's own: the save's
poi records, /locate poi, and a villager's search for a bed, bell or job site see one table.
Bees are the exception: they find a hive by its block entity and the minecraft:beehives tag,
not by its POI type, so a mod's hive also needs that tag.
The engine refuses, with one logged warning naming both types, an unknown POI type and a state
vanilla or an earlier mod already gives to a different type; the other additions still apply,
and adding a state to the type it already has changes nothing. Tag predicates are refused.
There are no new POI types: a mod extends vanilla's, as NeoForge's event does.
The extension table is process-wide, like the loaded mod set: re-collecting replaces
classification for every simultaneously live resource set. Villagers cache only vanilla
classification and consult the current extension table, just as saves and locate do.
Wasm subscriptions to this hook currently refuse at load with a reason naming
PoiTypeExtensions::metadata: its borrowed BlockMetadata accessor cannot yet cross
Wire. No wasm registration runs partially or silently omits metadata.
Block screen effects
native_extract_block_screen_effect(effect, state, pos, partial_tick, canceled, custom_draw) is NeoForge's ExtractBlockScreenEffectEvent: once a frame for each of the first-person view's block overlays that is shown, before its quads are built. Block is the view-blocking block the camera is inside, Water the block at the eye while it is under water, and Fire fire's default state at the player's block while they burn (first person only). None of them fire for a spectator. Set canceled to leave that one overlay out; the other overlays and the HUD's camera overlays (vignette, pumpkin, portal, freezing) stay.
Set custom_draw, NeoForge's setCustomRenderer, to draw the overlay yourself: block_screen_effect_draw is then asked about it later in the frame. A mod can't hand over a renderer object, so the flag stands in for the renderer and the hook for its submit. Vanilla's overlay still draws after the mod's, unless the draw hook sets replace_vanilla or the overlay was canceled. A canceled overlay with custom_draw set still gets the draw hook, and returning Flow::Cancel keeps a custom_draw set on the event; canceling without it is CustomBlockScreenEffectRenderer.NO_OP. Canceling skips later mods, so they neither see the overlay nor set custom_draw on it. The player and the camera are on ClientHost (local_player, camera, camera_entity, camera_type), and partial_tick is getWorldPartialTick, the same value the draw hook gets. getPlayerPartialTick is not provided. With no listener the frame pays one listened check.
Finalize spawn
finalize_spawn(dimension, kind, reason, pos, spawner, difficulty, spawn_cancelled, mob, edits, group, cancel)(NeoForge'sFinalizeSpawnEvent): fires where the game callsMob.finalizeSpawnfor a mob it made, before it runs. There are two cancels: returnFlow::Cancelto skip finalization (no equipment, attributes or class setup; the mob still spawns uninitialised, and finalization's random draws are skipped), or setspawn_cancelledso no mob is added.difficulty(Difficulty) is settable and is what finalization reads.spawnerisSpawnSource::Blockfor a spawner or trial spawner, none otherwise. Fired only where vanilla 26.3 callsfinalizeSpawn, with vanilla's reason at that call: natural spawning, chunk generation, spawners, trial spawners, patrols, raids and sieges, reinforcements, cat spawners, phantoms, jockeys,/summon,/raid, spawn eggs, dispensers, the nether portal's piglin (Structure), skeleton traps, evoker vexes, wandering traders and conversions. Not fired where vanilla creates the mob without it (breeding, an infested block's silverfish, a thrown egg's chick, a thrown pearl's endermite, hatching eggs, built golems, the oozing effect's slimes, a lightning trap's horse), for loads (persist.rsfinalizes a placed entity only through itshorizon:finalizemarker, which is a load) or for structure mobs placed by world generation. The mob being finalized is lent asmob(UnspawnedMobRef), a handle to a mob that is in no level yet: its reads take&dyn Host(kind,position,equipment,attribute_base,attribute_value,tags,custom_name), andedits(&dyn UnspawnedMobEdits, handed out with it) changes its equipment (ItemResourceand a count), attribute bases and permanent modifiers, tags and custom name. Each edit applies at once and reaches only that mob; after the hook returns, or with another lend's handle, every read and edit answersUnspawnedMobError::Expired. The editor is a scoped resource rather than a&mut dyn ServerHostbecause the hook fires inside spawning passes that hold the level shared (natural spawning, spawners), asCriterionContextis lent to a criterion test. Listeners run before finalization, so finalization's own equipment and attribute rolls overwrite a listener's edit to the same slot or attribute;Flow::Cancelkeeps every edit. Mobs that finalization creates (jockeys, a zombie horse's rider) get a lend of their own. The ender dragon, kept apart from mobs, answers its kind and position andUnsupportedfor the rest.group(&mut Option<SpawnGroup>) is the pack'sSpawnGroupData, converted at the boundary: the listener may read, change, set or clear it, and finalization reads what it leaves. Every live site hands itNonetoday: natural spawning does not thread group data through its packs yet, and chunk generation's animals share it only inside the generator, out of the hook's reach. The new types are plain data (Difficulty,SpawnSource,SpawnGroup,AgeableGroup,TropicalVariant,#[non_exhaustive]). Read-only host; listeners run on replayed ticks, so they are pure functions of their inputs and change onlymob(throughedits) andgroup. Tick-time effect: onelistenedbranch with no listeners; with one, a copy of the mob for the lend and back. Beyond NeoForge: none.
window_resize notifies client mods after a window resize or applying a changed GUI scale, fullscreen, or Unicode font setting. Its width and height are physical pixels, and gui_width and gui_height are scaled GUI pixels. It fires only when physical or scaled dimensions change.
Structure template snapshots
ServerHost::structure_template(id, at, rotation, mirror) reads a template without placing it.
It searches the world's saved templates before data packs and returns the rotated size and blocks
in vanilla placement order, with each transformed state and its saved block-entity data. It uses
the palette selected at at; processors and entities are not included. Invalid or missing names
return None. A builder can retain this snapshot and place its blocks over time.
Frame presentation
render_frame_post fires every frame once rendering returns, whatever became of
the frame. flip_frame fires after it, on the game's thread, and only when the
frame was presented. A frame presented on the game's thread (VSync, the default)
was already presented when render_frame_post fired: there the two differ only
on the frames that were skipped.
Nothing fires flip_frame for a frame that was skipped: the window is minimized
or has no drawable size, the swapchain gave no image (timeout, occluded, outdated
or lost), the device was lost, or the frame was a scaled screenshot that went to a
file. The closing save screen and the startup loading screens present frames of
their own, and those fire none of the frame hooks.
Where a frame goes decides what "presented" means when the hook runs:
- Presented on the game's thread: the present has returned.
- Presented on the submit thread: the game waits for that present before the hook runs. It waits only when a mod listens.
- Frame generation: the frame has been submitted and handed to the present thread, which shows it up to half a real frame later and can still fail to. The game does not wait, because that would hold back the next frame and undo frame generation. A made frame fires nothing of its own: the hook follows the real frames.
- Offscreen, with no swapchain (agent control, a splitscreen view): the frame has been submitted to its offscreen target.
Fluid flow
native_fluid_flow(context, fluid, pos, flow): edit the current (FlowingFluid.getFlow) of the fluid block atposas it pushes an entity: the player, mobs, dropped items, experience orbs, armor stands and arrows all read it through the one shared helper. It runs for each fluid block the entity touches in a tick, before the vanilla scale for shallow fluid, so keep the listener short. The renderer's own flow vector (a fluid texture's rotation) is not changed.level.fluid_flow(host, pos)reads the edited current. It fires on both sides and on the client's replayed ticks, with the loopcontextof the side that ticks the entity, so a mod registers on both and keeps the listener a pure function of its inputs. With no listener the cost is onelistenedbranch per fluid block. Beyond NeoForge (no counterpart); Diverges from upstream: new hook.
Fluid tooltips
native_fluid_tooltip(stack, lines)isFluidTooltipEvent: the tooltip of a fluid, with no item behind it.linesstarts as the game's own (horizon_client::fluid_tooltip::lines): the fluid's name (fluid_type.<mod>.<name>, vanilla's by its block), its amount ashorizon.fluid.amount("%s mB") and, with F3+H, its id. Each line is aText(translation-ready) with a colour asrgbapacks it. Add, change, reorder or remove lines; an empty list draws nothing. The game asks it where it draws a fluid slot itself (the recipe viewer's wells under the cursor), beforerender_tooltip_prelays the tooltip out. Nothing item-shaped fires for it:item_tooltipandrender_tooltip_gather_componentsbelong to stacks of items.
Foreground GUI and client lifecycle
Hooks::gui_render_post runs after the frame's GUI, including menus and the
F1-hidden HUD. Its batch is a DrawContext, not an engine HudBatch; use
its drawing methods and size() to place overlays. gui_hidden and focused
let a listener choose whether to draw. The GUI paint, nine-slice, and tooltip
operations also use this drawing context and the window's existing atlas.
Hooks::menu_background can adjust the requested blur radius or set it to
None before the game validates it.
Hooks::entity_outline receives the entity id, kind, and mutable optional RGB
outline for the current client render. Leave the existing value unchanged to
preserve vanilla glowing; None disables it. These read-only client hooks
receive &dyn ClientHost. They do not grant server writes.
Hooks::client_disconnecting runs before an actual connection is discarded,
not for a dimension change. Initialization and final shutdown still use the
existing client_setup and client_stopping hooks, with no parallel aliases.
Conditional item model properties
A client mod supplies its own namespaced minecraft:condition property by implementing
Hooks::item_model_property. This hook is the registration: match your property name, answer
through value, and leave other names untouched. The resolver calls it when choosing a branch,
not when loading resources. It defaults to false without a provider and skips dispatch entirely
once no loaded mod overrides it. Vanilla properties do not enter the hook.
fn item_model_property(
&self,
_host: &dyn ClientHost,
event: &mut ItemModelPropertyEvent<'_>,
) {
if event.property == "example:charged" {
event.value = event.stack.components().and_then(|patch| patch.as_nbt().get("example:charged"))
.is_some_and(|value| matches!(value, mod_api::Tag::Byte(1)));
}
}
ClientHost, ItemModelPropertyEvent, and ItemStack are exported by mod_api. The stack is an owned ItemStack snapshot,
including for nested bundle items: item(), count() and explicit components() (not merged
defaults). bundle_selected carries the runtime selection separately; -1 means none. Listeners run in mod order; later answers win.
Use "property": "example:charged" with both on_true and on_false in the item's
minecraft:condition node. Custom special model renderers are not supplied by this hook.
A minecraft:special item model whose model.type is another namespace's has no renderer in Horizon: the
item draws as its base model, and the log says so once per type. A mod gets the same result from model
JSON: a minecraft:model leaf naming a model file with elements and display draws cuboids with a
transformation per display context, tints colours them, and minecraft:select, condition and
range_dispatch on a property the mod answers through item_model_property pick between variants.
Combat hook ownership
The six combat hooks (living_incoming_damage, damage_pre, damage_post, living_knockback, living_use_totem, living_shield_block) are lent to each hit by the game that runs it, not installed once per process. A mod sees no difference: order, arguments and cancel rules are unchanged.
Migration (living_use_totem, living_shield_block, now version 2): both now take the hit's level host as their first argument, as living_incoming_damage does: its blocks, random and own attachments, with no writes beyond them. Add the parameter (level: &mut dyn LevelRandomHost); the event, its fields, its order and its cancel rules are unchanged. living_shield_block fires inside hurtServer before the armor and enchantment reductions and sees the hit's damage as it came in; living_use_totem fires after health reached 0 and before die. Tick-time effect: one extra pointer in the hit and the same listened early-outs. Diverges from upstream: the two hooks gain the level host that upstream's catalog rows did not have.
Attacker and held stacks (living_knockback, the weapon of living_incoming_damage): the attacker's id and the stacks it holds come from the hit that started the attack, not from process state, so a hit nested inside another (a thorns reply, a guest's attack during the host's) names its own attacker and nothing is left behind afterwards. No event field, order, random draw or cancel rule changes, so neither hook's version does. The game copies the held stacks only while a mod listens; without one the hit carries the attacker id and two empty slots, no allocation.
Living motion values
native_living_jump_power(context, entity, kind, power),native_living_speed(context, entity, kind, speed)andnative_living_flying_speed(context, entity, kind, speed): edit a living entity's jump power (LivingEntity.getJumpPower), movement speed (getSpeed) and airborne acceleration (getFlyingSpeed) where its travel and jump read them. They cover the player and the mobs' ground, fluid and jump paths (the rabbit's hop included); a mob that ships its own flying travel (an allay, a guardian) still reads its own speed. They fire on both sides and on the client's replayed ticks, so a mod registers on both and keeps each listener a pure function of its inputs.contextis the loop context of the side that ticks the entity (context.side(), registries, settings, mod list): the server's for a mob or a server player, the client's for its own prediction and replayed ticks. Contract version2: a mod written for1takes the context as a new first argument of theHooksmethod. With no listener the cost is onelistenedbranch per call. Beyond NeoForge (no counterpart); Diverges from upstream: new hooks. For per-block jump and speed factors seeblock_jump_factorandblock_speed_factorbelow.
Map decoration types
Hooks::map_decoration_types is the registration-phase counterpart of NeoForge 26.3's
RegisterEvent for Registries.MAP_DECORATION_TYPE. It receives a RegistrationContext, once at
startup on every side, and collects a MapDecorationType per type: an id <mod id>:<path>, the
GUI atlas sprite that draws it (assetId) and show_on_item_frame.
A registered type is a name a minecraft:map_decorations entry can use, exactly as a vanilla
one: an item's entry {type: "mymod:treasure", x, z, rotation} (what /give writes and
MapItemSavedData.addTargetDecoration adds) is drawn with the type's sprite on a held map, in an
item frame and on the cartography table's screen, and a type with show_on_item_frame false is
left out of the last two as vanilla's player arrows and markers are. The sprite is looked up in
the GUI atlas by name, so a pack's texture or a mod's runtime sprite
(Hooks::register_runtime_sprites, mymod:gui/treasure) works; a sprite the atlas lacks is left
out of the drawing. A mod type never counts toward a map's decoration limit (trackCount).
An id in vanilla's namespace, not of that form, registered already, or with an empty sprite is
logged and left out. Types are frozen once at startup; a type a
server's map data names that a client did not register is not drawn. Map data a Horizon server
sends to a client (ClientboundMapItemData) numbers vanilla's types only, so a mod type reaches a
client through the item's component, not through that packet.
Minecart pass
BlockPart::minecart_pass(host, event)(MinecartPassEvent { state, pos, entity }): a minecart passed over a rail block state the Part is attached to, at the moment vanilla callsactivateMinecartfor an activator rail, here for every rail. It is the Part form of Forge'sIBaseRailExtension.onMinecartPass, which NeoForge 26.3 no longer has, so there is no global hook. With the old minecart physics it fires once per tick the cart is on a rail; withminecart_improvementsonce per track iteration, so a fast cart can pass several times in a tick. It is recorded during the cart's step and delivered after the cart's batch has ticked, through the same drain asentity_inside, with a&mut dyn ServerHost, so a write the part makes (a velocity change throughEntityRef) lands on the stored cart. A pass whose cart is gone or whose rail changed is dropped. Only a rail state a Part listens for is recorded: with no such Part, oneblock_listenstest per rail step. Beyond NeoForge; Diverges from upstream: new hook.
Mob despawn
mob_despawn(dimension, mob, kind, pos, result)(NeoForge'sMobDespawnEvent, server): fired once per tick for every loaded mob just beforeMob.checkDespawn, including persistent mobs and mobs in peaceful.resultstarts asDespawn::Default.Allowdiscards the mob now,Denykeeps it this tick (a mod can keep a hostile mob in peaceful, as NeoForge's event documents), andDefaultleaves vanilla's rules to decide. An answer other thanDefaultskips vanilla's checks, so it also skips thenextInt(800)draw of the idle-despawn roll: the hook fires before that draw. A mob whose chunk is not loaded is not checked and the hook does not fire for it; the wither and the ender dragon overridecheckDespawnwithoutsuper, so it does not fire for them either. In vanilla the whole ofMob.checkDespawnfollows the event, the peaceful-mode discard included, so aDenykeeps a hostile mob in peaceful.- Listeners run on replayed ticks, so they are pure functions of their inputs. Tick-time effect: one
listenedbranch per mob per tick with no listeners; the answer sits in aMobfield that costs one byte and is reset after the mob's tick. - Beyond NeoForge: none.
Despawnis plain data,#[non_exhaustive].
Spawn checks
mob_spawn_placement_check(dimension, kind, reason, pos, default_result, level, spawner, result)(NeoForge'sMobSpawnEvent.SpawnPlacementCheck) andmob_spawn_position_check(dimension, kind, reason, pos, level, spawner, mob, result)(MobSpawnEvent.PositionCheck): setresulttoSpawnCheck::SucceedorFailto force a spawn check;Defaultkeeps vanilla's. The placement check runs after the type's spawn predicate (default_resultis its answer), so its random draws are vanilla's whatever a mod answers. The position check stands in forMob.checkSpawnRulesandcheckSpawnObstruction: with no listener orDefaultthey run and draw as vanilla, whileSucceedandFailskip them and their draws. Fired wherever the game checks a spawn: natural spawning, chunk generation, spawners, trial spawners (obstruction only), patrols, sieges and reinforcements for the placement check. Both getlevel(LevelRef, the level the mob would join) andspawner(Option<Spawner>,#[non_exhaustive]:Block { pos }for a spawner block,Trial { pos }for a trial spawner,Nonewhere no block asked: natural spawning, chunk generation, patrols, sieges, reinforcements and a spawner minecart). The position check also getsmob(UnspawnedMobRef), the mob itself: vanilla creates the mob before this check, and Horizon, which otherwise creates it after, creates it before the listeners only when the hook is listened, the way the site creates it, and hands that same mob on to the spawn, so the mob a listener read is the one that spawns; a refused check drops it, never added to the level, and gives its provisional id back. Creating it draws nothing from the level's random, so a listener that answersDefaultleaves every draw as an unlistened spawn's. Its reads arefinalize_spawn's (kind,position,equipment,attribute_base,attribute_value,tags,custom_name); there is no editor, as the hook only reads. Where the site does not create the mob fresh, the handle answerskindandpositionandUnsupportedfor the rest: chunk generation's packs (the generator creates and finalizes them with its own random, after the check) and a spawner or trial spawner whose spawn data carries entity data (the game loads it from that data after the tick). Both get the read-only host and run on replayed ticks, so a listener is a pure function of its inputs. Tick-time effect: onelistenedbranch with no listeners, before anySpawnReasonis built; with a position-check listener, the mob is created one step early, not twice. OneSpawnCheckserves both (NeoForge's two enums have the same constants).
Mod mismatch
A world records the code mods it was saved with at the root of level.dat, beside Data, under
fml.LoadingModList, the list NeoForge keeps: one {ModId, ModVersion}
compound per loaded mod, in load order, rewritten on every save of the world.
The version is the mod's horizon-mod.toml version, or "" when the game
doesn't know it. A world without the list (vanilla, or saved before Horizon
wrote one) is no mismatch: it fires nothing and gets the list on its next save.
Hooks::mod_mismatch runs whenever a saved list exists, even if empty or unchanged.
It runs on either logical side, once in singleplayer on the integrated server, before
world-state restoration. Raw-read-before-upgrade timing is not reachable without a
pre-world Host; preparation has already upgraded the world at this boundary.
Versions compare using the loader's semantic version parser (omitted minor and patch
are zero); empty or invalid versions compare by exact text. minecraft is excluded.
Push "your_mod".to_owned().into() into event.resolved to mark a migration.
Dispatch records the first resolving mod in each marker's resolver and preserves
prior resolutions against deletion or replacement. Entries are logged sorted by id:
resolved at debug (self or other resolver), unresolved at warn. Loading never blocks.
Model baking completed
model_baking_completed (NeoForge's ModelEvent.BakingCompleted) fires on the render thread once per successful model
load, right after both atlases' texture_atlas_stitched: on the first load before client_setup, and after every
successful resource reload before client_resources_reloaded. The store render_assets().model(&id) reads is already
the new one, the standalone models included. Resolve the models a mod draws there, once, and keep the ModelRefs until
the next model_baking_completed, when the old ones answer ModelError::StaleModel; a reload that fails fires nothing
and leaves them valid. It gives no view of the baked geometry and edits nothing (that is #861). A dedicated server never
fires it.
Player edge backoff
native_player_stays_on_ground_surface(context, entity, staying): edit whether the player counts as staying on the ground surface (Player.isStayingOnGroundSurface, vanilla: the shift key is down) wheremaybeBackOffFromEdgeasks, so a mod can make the player hold to an edge without crouching, or walk off one while crouching. It fires on both sides and on the client's replayed ticks, with the loopcontextof the side that ticks the player (see The loop context), so a mod registers on both and keeps the listener a pure function of its inputs. With no listener the cost is onelistenedbranch per movement. Beyond NeoForge (no counterpart); Diverges from upstream: new hook. Mobs never back off an edge, so there is no mob call site.
Block tint sources
A block tint source (NeoForge's RegisterColorHandlersEvent.BlockTintSources) colours a greyscale block model,
as vanilla colours grass and leaves: Hooks::register_block_tint_sources registers each block's list of layers
once at start, and a model face with tintindex i is multiplied by layer i. A layer is one of vanilla's sources
(VanillaTint: Constant, ConstantSplit, Grass, Foliage, Water, Redstone...) or the mod's own
BlockTintSource, with vanilla's four methods:
color(state, metadata): the colour away from a level (a block in a minecart, a mod's drawn block);color_in_world(state, metadata, level, pos): the chunk mesher's colour, and a block a piston moves; it defaults tocolor;color_as_terrain_particle(state, metadata, level, pos): the bits the block breaks into; it defaults tocolor_in_world;relevant_properties(): the properties the colour depends on. Vanilla groups states for shared baked models by them; Horizon bakes every state and does not read it.
struct Dyed;
impl BlockTintSource for Dyed {
fn color(&self, state: &BlockState, metadata: &dyn BlockMetadata) -> u32 {
match state.property(metadata, "color").ok().flatten() {
Some("red") => 0xFFB02E26,
_ => 0xFFF9FFFE,
}
}
}
fn register_block_tint_sources(
&self,
_context: &RegistrationContext,
event: &mut RegisterBlockTintSourcesEvent<'_>,
) {
event.sources.register(
vec![TintLayer::Source(Arc::new(Dyed)), VanillaTint::Grass.into()],
vec!["example:dyed_lamp".into()],
);
}
Colours are ARGB; the faces take their RGB, as vanilla's block renderer does. The mesher asks color_in_world
on its worker threads, once per block and tint index each time it meshes the section, so a source keeps no
mutable state and gets no Host. Each colour method receives an explicit immutable BlockMetadata resource, also used by BlockState::property, with and of. level is a BlockAndTintGetter over the section's chunk and its eight
neighbours: block and fluid states, light, face shading and block_tint(pos, resolver) for the biome colours.
Block entities are not part of it. A block with no mod layers meshes as before, and the registrations hold after
a pack change or F3+T. Fluids take their colour from their fluid model, not from these registrations.
Cauldron interactions
register_cauldron_interaction registers exact item callbacks on both sides. See Registering cauldron interactions for the read-only decision, transactional fire, duplicate refusal and server-corrected prediction contract.
Colour resolvers
A colour resolver (vanilla's ColorResolver, NeoForge's RegisterColorHandlersEvent.ColorResolvers) is a colour
calculated from a biome and a block column, which the game blends over the biomes around a block as it blends
grass, foliage and water: each block averages the resolver's colour over the (2r+1)^2 columns of the biome blend
radius, each column in the biome the game zooms it to. Hooks::register_color_resolvers registers the mod's own
BiomeColorResolver once at start and gets back the ColorResolver to ask for, and the mod's BlockTintSource
reads it with level.block_tint(pos, resolver):
static FROST: OnceLock<ColorResolver> = OnceLock::new();
struct Frost;
impl BiomeColorResolver for Frost {
fn color(&self, _biome: &str, info: &ResolverBiome, _x: f64, _z: f64) -> u32 {
if info.temperature < 0.15 { 0xE0F0FF } else { info.colors.foliage }
}
}
struct FrostLeaves;
impl BlockTintSource for FrostLeaves {
fn color(&self, _: &BlockState, _: &dyn BlockMetadata) -> u32 {
0xFF48B518
}
fn color_in_world(
&self,
_: &BlockState,
_: &dyn BlockMetadata,
level: &dyn BlockAndTintGetter,
pos: [i32; 3],
) -> u32 {
level.block_tint(pos, *FROST.get().expect("registered at start"))
}
}
fn register_color_resolvers(
&self,
_context: &RegistrationContext,
event: &mut RegisterColorResolversEvent<'_>,
) {
FROST.get_or_init(|| event.resolvers.register(Arc::new(Frost)));
}
color(biome, info, x, z) gets the biome's id, its temperature, downfall and colours (after biome_colors) and the
block column, and returns 0xRRGGBB. The mesher asks it on its worker threads, once per column the section's tinted
blocks blend over, so a resolver keeps no mutable state and gets no Host. Its colours are cached for one mesh of
one section only: a resource or pack reload, a biome colour refresh and a block change all mesh the section again
and ask the resolver again.
Debug screen entries
Hooks::register_debug_entries is the client registration-phase counterpart of NeoForge
26.3's RegisterDebugEntriesEvent. It receives a RegistrationContext, once at client
startup, and collects a DebugEntry per entry: an id <mod id>:<path> and the DebugGroup
whose vanilla group its lines join (Position, Light, Memory, LookingAtBlock,
LookingAtEntity, LookingAtFluid or ChunkGeneration). An id in vanilla's namespace, not
of that form, or registered already is logged and left out.
An entry is listed in the debug options' text section, sorted by its id with vanilla's, and
is switched like them (alwaysOn, inOverlay, never), saved in debug-profile.json under
its full id. On the overlay, its group comes after vanilla's groups.
Its lines are asked for each frame the overlay draws it, and only while it is on:
Hooks::debug_entry_lines receives the entry's id and a &mut Vec<String> to push lines to,
with legacy colour codes (§) working. A disabled entry, or one with no lines, costs nothing.
Nothing is uploaded or recorded: the lines are drawn on the local screen only.
An entry in DebugGroup::Renderer is vanilla's DebugEntryNoop: it has no lines, and is listed
in the renderer section instead. It is the switch of the DebugRenderer with its id, see
debug renderers.
Debug renderers
Hooks::register_debug_renderers is the client counterpart of NeoForge 26.3's
RegisterDebugRenderersEvent, and like it fires during vanilla's
DebugRenderer.refreshRendererList: once as the client starts, on each login, on the first view
of a world since the start, and on the next view of a world after the debug selection changes.
That is a status set in the debug options or by a key, a profile loaded, the F3 overlay opened or
closed, or reduced debug info changed. It is not asked on other views, nor on title or connect
screens. It receives a RegistrationContext, so it reads no world, and collects a DebugRenderer
per renderer.
Each refresh replaces the active list, so a mod pushes every renderer it has, each time, and may
rebuild its renderers' state here. Pushing an id does not list an option, since the option must
exist before any refresh. As in vanilla, the option is an entry registered once at start:
Hooks::register_debug_entries with DebugGroup::Renderer and the same id. It is listed in the debug options' renderer section, sorted by its id with
vanilla's, and is switched like them (alwaysOn, inOverlay, never), saved in
debug-profile.json under its full id. It starts off, and reduced debug info hides it, as it
hides vanilla's. A renderer whose entry is off is left out of the active list, and one with no
such entry is logged and left out.
While a renderer is active, Hooks::debug_renderer_draw is asked for each view of a world, after
submit_custom_geometry, with the renderer's id, the same WorldRenderView and the same
WorldDrawContext, so its limits and rules apply and are shared. Every
mod is asked about every id, so a mod ignores ids it does not own. Output clears every view and
title or connect screens ask no renderer, so nothing stays after leaving a world. A renderer that
is not active is never asked, so with none active a view only compares the selection's version.
Dimension transition screens
Hooks::register_dimension_transition_screen is the client registration-phase counterpart of
NeoForge's RegisterDimensionTransitionScreenEvent. Push DimensionTransitionScreen records into
event.screens, with a namespaced screen id and DimensionTransition::Incoming(dimension),
Outgoing(dimension) or Conditional { to, from }. Conditional pairs win over incoming, then
outgoing. The first registration for each transition wins; malformed ids and screen ids in the
minecraft namespace are logged and skipped. Dimension ids are resolved once during registration.
The screen is selected once at ClientPacketListener.startWaitingForNewLevel, including login
(where there is no source dimension) and respawn. Instead of a Java screen factory, the selected
id reaches Hooks::dimension_transition_screen_draw with its reason and current optional server
progress. Draw the complete presentation using event.draw; ignore screen ids you do not own.
The existing level-load tracker owns readiness, the player-section wait, timeout, and closing.
No registration changes any of those rules. An unmatched transition draws vanilla's screen.
fn register_dimension_transition_screen(
&self,
_context: &RegistrationContext,
event: &mut RegisterDimensionTransitionScreenEvent<'_>,
) {
event.screens.push(DimensionTransitionScreen {
screen: "example:arrival".into(),
transition: DimensionTransition::Incoming("example:moon".into()),
});
}
fn dimension_transition_screen_draw(
&self,
_host: &dyn ClientHost,
event: &mut DimensionTransitionScreenDrawEvent<'_>,
) {
if event.screen == "example:arrival" {
let [w, h] = event.draw.size();
event.draw.fill([0.0, 0.0, w, h], 0xFF101030);
}
}
Custom enchantment damage values
register_enchantment_damage_effects registers an EnchantmentDamageEffect under a non-vanilla
Identifier before content loads. Use that identifier as a value node's type in an enchantment's
minecraft:damage component. The callback receives a read-only ServerHost, actual attacker and
victim handles (LivingRef and EntityRef), a pre-hit weapon snapshot, enchantment level and the node's configuration as a borrowed NBT Tag, converted once at load.
Return the transformed damage value. NaN retains the input; other outputs (including infinities)
are clamped to 0..=1024 before subsequent nodes, bounding per-unit built-in work. Registration rejects duplicate and vanilla identifiers.
The engine resolves identifiers during content loading, including resource/data pack reloads.
Unknown identifiers reject the containing enchantment with a diagnostic naming both identifiers.
Built-in nodes, requirements and nested minecraft:all_of keep their original order and random
consumption. The callback runs at EnchantmentHelper.modifyDamage during ordinary damage computation,
before incoming-damage hooks and armor reductions. It is an effect registrar, not a generic hit hook.
Callbacks are indexed directly by resolved slots; hits never scan installed mods. Hits without custom
nodes retain the vanilla evaluator without constructing API weapon snapshots.
Entity spectator shaders
Hooks::register_entity_spectator_shaders is the client registration-phase counterpart of NeoForge's
RegisterEntitySpectatorShadersEvent. Push EntitySpectatorShader records into event.shaders: the
spectated entity type and the post effect it puts on the screen. The post effect is an ordinary
post chain, assets/<namespace>/post_effect/<path>.json, which the mod's assets or a resource pack
supply, so its passes follow the same pack rules as vanilla's minecraft:creeper and
minecraft:invert; no shader code or GPU object crosses the mod API.
When the first-person view follows an entity (spectator mode, or a server's camera), the game
applies the registered effect for that entity's type (GameRenderer.checkEntityPostEffect). The
third-person view and leaving the camera clear it. The first registration for each entity type wins:
vanilla's creeper, spider, cave spider and enderman count as first, so a mod can't change what the
vanilla mobs show. A later registration for the same type, and one whose ids aren't namespaced, is
logged and skipped. Registered chains compile in the background with vanilla's, from the first frame
in a world.
fn register_entity_spectator_shaders(
&self,
_context: &RegistrationContext,
event: &mut RegisterEntitySpectatorShadersEvent<'_>,
) {
event.shaders.push(EntitySpectatorShader {
entity: "example:wisp".into(),
shader: "example:wisp_vision".into(),
});
}
In-world game tests
register_game_tests runs once per world open before test-instance definitions resolve their functions.
Register an Arc<dyn GameTestFunction> through event.functions.register("example:check", callback).
Add a test with event.functions.register_test(id, GameTestDefinition::new(structure, function)),
or use a data-pack test_instance with type minecraft:function and function example:check selects it.
The callback receives a ServerHost and GameTestContext: use context.level for level handles and
context.absolute_pos(relative) for rotated structure coordinates. Return Pass, Fail(message) or
Pending; pending attempts keep the ordinary timeout. Each retry invokes the function again.
Definitions retain resolved callbacks only for their world snapshot. Unknown functions fail normally;
minecraft:always_pass remains built in. Data-pack test definitions override code-registered defaults. Registration refuses duplicates, invalid names and the vanilla
namespace. This adds no sequence engine and no per-tick registry lookup.
HUD layers
Hooks::register_gui_layers is the client registration-phase counterpart of NeoForge 26.3's
RegisterGuiLayersEvent. It receives a RegistrationContext, once at client startup, and
collects a GuiLayer per layer: an id <mod id>:<path> and a GuiLayerPlacement, which is
BelowAll, Below(id), Above(id) or AboveAll, as registerBelowAll, registerBelow,
registerAbove and registerAboveAll. Below and Above name a vanilla layer (the ids
Hooks::gui_layer_pre lists) or a layer pushed before this one; order against your own layers,
since other mods' layers depend on load order. Layers are placed in push
order, in mod load order. An id in vanilla's namespace, not
of that form, registered already, or placed against a layer that is not in the list yet is
logged and left out.
Each frame the HUD shows, every layer is drawn in that order. A mod's layer fires
Hooks::gui_layer_pre with its id (a cancel hides it), then Hooks::gui_layer_draw, then
Hooks::gui_layer_post, as every layer does. So another mod
hides or wraps a mod's layer the way it hides or wraps a vanilla one, and replacing a layer
(replaceLayer, wrapLayer) is a cancel in gui_layer_pre and a draw of its own. Every mod
is asked about every id in gui_layer_draw, so a mod ignores ids it does not own.
A vanilla layer Horizon does not draw this frame (the crosshair in third person, the mount's
health off a mount) still keeps its place: the mod layers around it are drawn in order, at the
next layer that is. As for vanilla's layers, F1 and a canceled Hooks::render_gui_pre hide
every mod layer; a mod that
draws under F1 uses Hooks::gui_render_post.
Item model types
An item definition (assets/<namespace>/items/<path>.json) is a tree of model nodes: vanilla's minecraft:model,
condition, select, range_dispatch, composite, special and the rest. A mod adds a node type of its own
(NeoForge's RegisterItemModelsEvent) in two steps, as an item tint source: Hooks::register_item_models
pushes the type id once at start, before resources load, and Hooks::item_model_update answers each time an item
whose definition holds that node is drawn.
The node lists the models it can draw in a models object; they are ordinary nodes, decoded and baked by the
game. Per stack, the mod pushes the names of the ones to draw, in order:
fn register_item_models(
&self,
_context: &RegistrationContext,
event: &mut RegisterItemModelsEvent<'_>,
) {
event.models.push("example:charge".into());
}
fn item_model_update(&self, _host: &dyn ClientHost, event: &mut ItemModelUpdateEvent<'_>) {
if event.kind == "example:charge" {
let name = if event.stack.count() > 1 { "full" } else { "empty" };
event.models.push(name.into());
}
}
{"model": {"type": "example:charge",
"models": {"empty": {"type": "minecraft:model", "model": "example:item/cell_empty"},
"full": {"type": "minecraft:model", "model": "example:item/cell_full"}}}}
The node's whole object reaches the hook as event.config, converted once at load, and its transformation
applies to every model it draws. A name the node's models doesn't have draws nothing, and so does a node no
mod answers. A node type no mod registered is an error naming it: the item definition holding it isn't loaded,
and the item draws the missing model.
Item tint sources
An item tint source (NeoForge's RegisterColorHandlersEvent.ItemTintSources) colours a greyscale item per
stack, in two steps as well: Hooks::register_item_tint_sources pushes its id once at start, before resources
load, and Hooks::item_tint_source answers the ARGB colour for each stack, in hand, in a screen or on the ground.
fn register_item_tint_sources(
&self,
_context: &RegistrationContext,
event: &mut RegisterItemTintSourcesEvent<'_>,
) {
event.sources.push("example:charge".into());
}
fn item_tint_source(&self, _host: &dyn ClientHost, event: &mut ItemTintSourceEvent<'_>) {
if event.source == "example:charge" {
let rgb = match event.config.get("default") {
Some(Tag::Int(value)) => *value as u32,
_ => 0xFFFFFF,
};
let rgb = if event.stack.count() > 1 { 0x55FF55 } else { rgb };
event.color = 0xFF000000 | rgb;
}
}
Use it as an entry of a minecraft:model's tints, {"type": "example:charge", "default": 65280}: the entry's
place in the list is the tintindex it colours, and its object reaches the hook as event.config. A resource
reload keeps reading the mod's colour. Ids are checked as for numeric properties. A source no mod registers draws
white, decided once at load, and so does an entry no mod answers; an item with no mod source never reaches the hook.
Entity model layers
Hooks::register_layer_definitions is the client registration-phase counterpart of NeoForge 26.3's
EntityRenderersEvent.RegisterLayerDefinitions. It receives a RegistrationContext, once at
client startup, and collects (id, LayerDefinition) entries in dependency order. Dedicated
and integrated server roles do not fire this client event. IDs are <mod id>:<model>#<layer>;
vanilla-namespace, malformed and duplicate IDs are logged and left out.
A LayerDefinition holds a MeshDefinition of named parts whose CubeListBuilder boxes sit
at a PartPose, on a texture of the given size. Texture offsets, mirroring and
CubeDeformation match vanilla's model builders. This listener is compiled in the API tests:
fn register_layer_definitions(
&self,
_context: &RegistrationContext,
event: &mut RegisterLayerDefinitionsEvent<'_>,
) {
let mut mesh = MeshDefinition::new();
let root = mesh.root();
let head = CubeListBuilder::create()
.tex_offs(0, 0)
.add_box([-4.0, -8.0, -4.0], [8.0, 8.0, 8.0]);
mesh.add_or_replace_child(root, "head", head, PartPose::offset(0.0, 6.0, 0.0));
event
.layers
.push((self.0.into(), LayerDefinition::create(mesh, 64, 32)));
}
ClientHost::bake_layer bakes a registered layer or a vanilla layer by its
entity-models.json name (minecraft:zombie#main) into an owned ModelPart tree. Bake once
and keep it. Before each frame, call reset_all_poses and set the parts' positions and
rotations as setupAnim does. A layer unavailable from the cached mob types is read from
resources for each bake, so do not bake it every frame.
Draw the tree with BlockEntityDrawContext::model_part, using the same draw context the mob
or block entity submit hook received. There is no separate Host drawing helper. Positions
are texels, +y down and the front towards -z; convert the mob's frame with scale
[1.0, -1.0, -1.0] then translation [0.0, -1.501, 0.0], as LivingEntityRenderer does.
The sprite is the entity texture ID and the tint is 0xRRGGBB.
Model loaders
A model JSON may name a loader: NeoForge's neoforge:composite and neoforge:obj are built in, and a mod
adds one of its own (NeoForge's ModelEvent.RegisterLoaders) in two steps, as a sprite source:
Hooks::register_model_loaders pushes the loader id once at start, before resources load, and
Hooks::bake_model_loader answers the quads of each model that names it, when the model is baked on every
load and reload. A loader is for geometry the vanilla elements can't express; a model that only needs
cuboids uses elements.
fn register_model_loaders(
&self,
_context: &RegistrationContext,
event: &mut RegisterModelLoadersEvent<'_>,
) {
event.loaders.push("example:pillar".into());
}
fn bake_model_loader(&self, _context: &RegistrationContext, event: &mut BakeModelLoaderEvent<'_>) {
if event.loader != "example:pillar" {
return;
}
// The north face of a 4-pixel post in the middle of the block.
event.quads.push(ModelLoaderQuad {
positions: [[0.625, 0.0, 0.375], [0.375, 0.0, 0.375], [0.375, 1.0, 0.375], [0.625, 1.0, 0.375]],
uvs: [[1.0, 1.0], [0.0, 1.0], [0.0, 0.0], [1.0, 0.0]],
texture: "side".into(),
cull: None,
tint_index: -1,
shade: true,
});
}
The model's whole JSON object reaches the hook as event.config ({"loader": "example:pillar", "textures": {"side": "example:block/post"}, ...}), and event.model names it. A quad's texture is a key of the model's
textures, resolved through its parents as an element face's is. Quads are in block units, the way an obj
model's are, and the blockstate variant's rotation moves them whole. Each model is asked once per load.
A model over 65,536 quads is refused whole, with a reason. A quad with a position or UV that isn't finite, or
a texture key the model doesn't define, is skipped by itself with a reason. A loader no mod registered is
logged as an error naming it and the model, which then draws only its elements.
Particle groups
Hooks::register_particle_groups is the client registration-phase counterpart of NeoForge
26.3's RegisterParticleGroupsEvent. It receives a RegistrationContext, once at client
startup, and collects a ParticleGroup per render group: an id <mod id>:<path>, a
ParticleBlend (Opaque or Translucent) and the particle types that draw in it.
fn register_particle_groups(
&self,
_context: &RegistrationContext,
groups: &mut Vec<ParticleGroup>,
) {
groups.push(ParticleGroup {
id: "example:glow".into(),
blend: ParticleBlend::Translucent,
particles: vec!["example:orbit".into(), "example:spark".into()],
});
}
A group is vanilla's ParticleRenderType with the ParticleGroup the particle engine keeps for
it. Its particles are counted, admitted, ticked and removed apart from every other group's, so
each group has vanilla's own limit of 16384 live particles and a crowd in one cannot crowd out
another, and they are drawn after the built-in particles, groups in registration order. The group
decides the layer of its particles (blend), whatever their sprites hold. A type is a particle
type particles/<path>.json gives a sprite set (ParticleOptions::named("example:spark")), with
or without a register_particle_providers provider, so a group adds no particle look or motion
of its own: that stays in the declaration and the provider. A type no group lists draws in the
built-in group as before, and the built-in particles are never moved. A type another group lists
already, or no sprite set matches, is logged and left out; an id in vanilla's namespace, not of
that form, or registered already is logged and the group is left out.
The game resolves a type's group once, when the particle sprite sets load (on every resource load), and each particle carries its group's handle, so a spawn, a tick and a draw look nothing up by name and scan no mod. Live provider-driven particles are dropped when resources reload, in a group as in the built-in one.
Particle providers
A particle provider (NeoForge's RegisterParticleProvidersEvent and ParticleProvider) gives a mod's own
particle type code. Hooks::register_particle_providers registers one provider per type name once at start,
before resources load, and the game resolves each type's provider when the particle sprite sets load, so a
particle tick dispatches to the provider that owns it: no lookup, no scan over the mods.
struct Orbit;
impl ParticleProvider for Orbit {
fn tick(&self, particle: &mut ModParticle) {
let t = f64::from(particle.age) * 0.4;
particle.position = [
particle.state[0] + t.cos(),
particle.state[1] + f64::from(particle.age) * 0.05,
particle.state[2] + t.sin(),
];
particle.rgba[3] = 1.0 - particle.age as f32 / particle.lifetime as f32;
if particle.age > 40 {
particle.removed = true;
}
}
}
fn register_particle_providers(
&self,
_context: &RegistrationContext,
event: &mut RegisterParticleProvidersEvent<'_>,
) {
event.providers.register("example:orbit".into(), Arc::new(Orbit));
}
The particle is built as a declarative one is: ParticleOptions::named("example:orbit") draws the sprite set
of assets/example/particles/orbit.json, with the spawn values of its horizon:behaviour (lifetime, size, alpha,
velocity). The engine keeps the sprite handling and the camera-distance, Particles-setting and population limits;
only the motion is the provider's. Before each tick the game advances age (1 on the first tick) and sets
previous, and it expires the particle once age passes lifetime, or after a tick that sets removed or
leaves a number that is not finite. The particle draws on the blended layer, so rgba[3] may fade it, unless a register_particle_groups
group lists its type: it then draws in that group, on the group's blend.
position, frame (a sprite of the set, clamped), size, rgba, lifetime and removed are the tick's to
write. state is four numbers that stay with the particle, zero at spawn; it is the only per-particle data,
since every particle of a type shares the one provider (the same for native and wasm mods: a wasm tick is one
call with the particle copied in and out). A tick has no Host and no level, since the engine ticks particles
below gameplay; a provider that needs the world reads it in a hook. A type with no provider keeps its
declarative motion, and live provider particles are dropped when resources reload, as a compiled
controller's are.
World preset editors
register_preset_editors is the client registration-phase counterpart of NeoForge's
RegisterPresetEditorsEvent. Push PresetEditor { preset, screen } into event.editors.
The preset comes from the enabled data packs' worldgen/world_preset JSON; registering
an editor alone does not create a preset. Editor ids must be namespaced outside minecraft.
Add the preset to data/minecraft/tags/worldgen/world_preset/normal.json to offer it
in Create World's type selector; unlisted presets remain hidden.
fn register_preset_editors(
&self,
_: &RegistrationContext,
event: &mut RegisterPresetEditorsEvent<'_>,
) {
event.editors.push(PresetEditor {
preset: "example:islands".into(),
screen: "example:islands_editor".into(),
});
}
Customize opens world_preset_editor, with Done and Cancel supplied by the game.
Use the existing screen render and input hooks for the custom presentation and controls.
host.preset_editor() identifies the selected editor; host.preset_editor_dimensions()
returns its working dimension stems. host.set_preset_editor_dimensions(...) replaces
that working copy after structural validation. Done commits it to world creation;
Cancel and Escape discard it. Neither path changes an existing world. Screen drawing
and input work before a world is loaded and use the same API on native and wasm.
Keep custom drawing above the bottom 33 GUI pixels reserved for Done and Cancel.
Dimension ids, stem structure and flat-layer height bounds are checked before accepting
an edit; registry resolution and generator-specific decoding still run when opening the world.
Numeric item model properties
A client mod adds a numeric minecraft:range_dispatch property (NeoForge's
RegisterRangeSelectItemModelPropertyEvent) in two steps. Hooks::register_range_select_item_model_property
pushes its id once at start, before resources load; Hooks::item_model_range_property answers the number
for each stack while a model picks a branch.
fn register_range_select_item_model_property(
&self,
_context: &RegistrationContext,
event: &mut RegisterRangeSelectItemModelPropertyEvent<'_>,
) {
event.properties.push("example:fill".into());
}
fn item_model_range_property(&self, _host: &dyn ClientHost, event: &mut ItemModelRangePropertyEvent<'_>) {
if event.property == "example:fill" {
event.value = event.stack.count() as f32 / 64.0;
}
}
Use "property": "example:fill" in the item's minecraft:range_dispatch: the model's scale multiplies the
number, then the highest threshold at or below it wins, else fallback, as for vanilla's properties. A
resource reload keeps reading the mod's number. An id in vanilla's namespace, not <namespace>:<path>, or
registered twice is logged and left out. A property no mod registers reads 0, decided once at load.
Render pipelines
Hooks::register_render_pipelines is the client registration-phase counterpart of NeoForge's
RegisterRenderPipelinesEvent (and of 1.21.1's RegisterShadersEvent, which it replaced). A
pipeline is plain data: its id, a WGSL fragment shader in the mod's assets, a blend mode and a
depth mode. No GPU object or shader handle crosses the mod API, so a native mod and a wasm mod
register the same way.
fn register_render_pipelines(
&self,
_context: &RegistrationContext,
event: &mut RegisterRenderPipelinesEvent<'_>,
) {
event.pipelines.push(RenderPipeline::new(
Identifier::parse("example:glow").unwrap(),
Identifier::parse("example:glow").unwrap(),
PipelineBlend::Additive,
WorldDepth::Tested,
));
}
The shader example:glow is assets/example/shaders/pipeline/glow.wgsl. It defines one function;
the game supplies the vertex stage, the bindings and the conversion to the frame:
fn pipeline_fragment(input: PipelineInput) -> vec4<f32> {
let pulse = 1.0 + sin(frame.time * 4.0);
return vec4(input.color.rgb * pulse, input.color.a * (1.0 - input.uv.y));
}
PipelineInput has uv (the quad's texture coordinate), color (the quad's colour) and
position (blocks, relative to the camera). frame.time is seconds, wrapping every hour, and
frame.viewport is the world picture's size in pixels. Colours follow the
mod render colour rule: the function reads and returns linear
light, 1.0 is paper white, more is brighter, and alpha is coverage. A file that declares its own
bindings or entry points is refused.
Resolve the pipeline with host.render_assets().render_pipeline(&id) in client_setup and again
in client_resources_reloaded, then draw with WorldDrawContext::pipeline_quads from
submit_custom_geometry or render_level_stage. A resource reload builds every registered
pipeline again, so a resource pack can replace a shader; a handle from before the reload is
Stale. Pipeline quads share the view's 4,096 quads with WorldDrawContext::quads, and in each
stage they draw after that stage's plain quads and lines, in the order submitted. Under a shader
pack they follow the plain quads: a stage the pack draws itself is drawn with the next stage the
game reaches.
Runtime sprites
register_runtime_sprites receives a registration context and a collection of
RuntimeSprite declarations. Use a mod namespace and an id such as
mymod:block/marker for a block sprite or mymod:gui/marker for a GUI sprite.
Push a static PNG no larger than 256 by 256 pixels or 256 KiB. The load accepts
up to 128 sprites and one million source pixels across all mods. Existing pack
textures win and the first accepted registration of a path wins. Invalid,
duplicate and over-budget entries are logged and skipped individually.
The hook runs at client startup and on the game-owning thread before reload
staging. Re-submit
sprites on each call. Look them up with texture_sprites or gui_sprite after
texture_atlas_stitched, then reacquire regions after a successful reload.
A failed reload keeps the published atlases. No generated PNGs are written to disk.
Categorical item model properties
A categorical minecraft:select property (NeoForge's RegisterSelectItemModelPropertyEvent) works the same
way: Hooks::register_select_item_model_property pushes its id once at start, and
Hooks::item_model_select_property answers a string for each stack.
fn register_select_item_model_property(
&self,
_context: &RegistrationContext,
event: &mut RegisterSelectItemModelPropertyEvent<'_>,
) {
event.properties.push("example:mode".into());
}
fn item_model_select_property(&self, _host: &dyn ClientHost, event: &mut ItemModelSelectPropertyEvent<'_>) {
if event.property == "example:mode" {
*event.value = Some("dig".into());
}
}
Each when value in the item's minecraft:select on example:mode is a string (or a list of them); the case
whose value equals the answer is drawn, else fallback, also when no mod answers. A resource reload keeps
reading the mod's value. Ids are checked as for numeric properties. A select on a property no mod registers
draws its fallback, decided once at load.
Sprite sources
An atlas definition (assets/<namespace>/atlases/<path>.json) lists the sources that give the atlas its
sprites: vanilla's directory, single, filter, unstitch and paletted_permutations. A mod adds a
source type of its own (NeoForge's RegisterSpriteSourcesEvent) in two steps, as an item tint source:
Hooks::register_sprite_sources pushes the type's id once at start, before resources load, and
Hooks::sprite_source answers each entry of that type when a definition is read, on every load and
reload.
fn register_sprite_sources(
&self,
_context: &RegistrationContext,
event: &mut RegisterSpriteSourcesEvent<'_>,
) {
event.sources.push("example:checker".into());
}
fn sprite_source(&self, _context: &RegistrationContext, event: &mut SpriteSourceEvent<'_>) {
if event.source != "example:checker" {
return;
}
// A JSON number arrives as the narrowest tag that holds it: `8` is a `Tag::Byte`.
let side = match event.config.get("size") {
Some(Tag::Byte(size)) => (*size).clamp(1, 64) as u32,
_ => 16,
};
let rgba = (0..side * side)
.flat_map(|i| if (i / side + i % side) % 2 == 0 { [255, 0, 255, 255] } else { [0, 0, 0, 255] })
.collect();
event.output.push(SpriteSourceOutput::Pixels {
sprite: Identifier::parse("example:checker").unwrap(),
width: side,
height: side,
rgba,
});
}
Use it as an entry of a definition's sources, {"type": "example:checker", "size": 8}: the whole object
reaches the hook as event.config, event.atlas names the atlas, and event.listed holds the sprites the
entries before it listed. A source pushes SpriteSourceOutputs, which apply in order as vanilla's
SpriteSource.Output calls do: Texture adds a texture of the resources as a sprite, Pixels adds pixels
the mod made, and Remove drops a listed sprite (vanilla's removeAll, by id). A sprite of an id listed
already replaces the earlier one, as a built-in source's does.
Sprite sources apply to the atlases built from their definitions: the atlases mods register
(register_texture_atlases). An entry whose type no mod registered fails its definition, as an unknown type
fails it in vanilla, and the log names the type and the entry's place. A Texture the resources lack, and
Pixels whose length isn't width * height * 4 or whose side is over 32768 pixels, are logged and skipped
one by one.
Texture atlases
register_texture_atlases receives a registration context and a collection of
TextureAtlasRegistration declarations: an atlas id in the mod's own namespace
(mymod:icons) and its definition (mymod:icons reads
assets/mymod/atlases/icons.json). The definition is a sources list like
vanilla's atlas definitions, and resource packs change it the same way.
Horizon packs vanilla's atlases into two, so a registered atlas's sprites are
stitched into the GUI atlas, each under TextureAtlasRegistration::sprite_name:
sprite mymod:heart of mymod:icons is mymod:gui/atlases/icons/mymod/heart.
Look it up with gui_sprite in texture_atlas_stitched for TextureAtlas::Gui
and draw it with DrawContext::sprite by that name. Every reload lists the
sprites again from the definition as the packs now give it.
The hook runs at client startup and on the game-owning thread before reload
staging; re-submit the atlases on each call. The game's own atlases never
change: atlases in the minecraft or horizon namespace are refused. The load
accepts up to 32 atlases and 4096 of their sprites across all mods. The first
registration of an atlas id wins; an unknown or malformed definition, a
duplicate atlas and sprites past the budget are logged and skipped. A pack file
already at a sprite's name wins over the sprite.
Item frame rendering
native_render_item_in_frame(entity_id, pos, stack, canceled) runs before a visible item frame draws its item or map, including glow and invisible frames. Set canceled to hide the item without hiding the frame.
World render stages
Hooks::render_level_stage is the client counterpart of NeoForge 26.3's
RenderLevelStageEvent for the stages Horizon draws, as one hook with a RenderLevelStage:
AfterSky (behind everything else, for backdrops and atmosphere), AfterOpaqueBlocks (after
solid and cutout terrain, before entities), AfterTranslucentBlocks (after water and stained
glass, before translucent particles), AfterWeather (after rain and snow, before the world
border) and AfterLevel (with Hooks::submit_custom_geometry, before the hand). It is asked
for each stage in that order, every view of a world, and never on title or connect screens.
Horizon gathers a view's geometry before rendering it, so every stage is asked as the view is
extracted, in stage order, with Hooks::submit_custom_geometry and the debug renderers asked
just before AfterLevel. All take the same WorldRenderView and WorldDrawContext:
the custom geometry limits and rules apply and are shared by all stages. Only where it is drawn differs. draw_model_instances copies
are part of the world's model pass, so they draw with entities whatever the stage.
A route that does not draw a stage's part of the world in the world pass draws that stage's geometry at the next stage it reaches instead, depth tested as usual: a shader pack that draws the sky, terrain, water or weather itself, and improved transparency for the translucent and weather stages. Nothing is dropped, and the order between stages is kept.
Delayed tasks
host.schedule_task(delay_ticks, token) asks for a call back after delay_ticks server ticks
and host.cancel_task(id) withdraws it, the stand-in for vanilla's
MinecraftServer.tell(new TickTask(getTickCount() + delay, runnable)). A closure cannot cross
into a wasm mod or outlive the borrowed host, so the callback is this hook: Hooks::scheduled_task
gets the TaskId, the owner (the scheduling mod's [mod] id) and the token the mod passed,
on the server thread, at the start of the due tick right after server_tick's start phase. Every
mod is asked about every task, so a mod ignores tasks whose owner is not its own.
A delay counts ticks the server runs: a paused integrated server does not advance it, and 0
and 1 both mean the next tick. Tasks due on the same tick run in the order they were
scheduled. A task scheduled from scheduled_task itself runs no earlier than the next tick.
schedule_task must be called from inside a mod's hook (ScheduleError::NotInMod otherwise),
and a mod may have MAX_PENDING_TASKS waiting at once (ScheduleError::TooMany). Ids are
never reused in a world. cancel_task answers true if the task was still waiting and the
caller's own; a fired, unknown or another mod's id answers false.
The queue belongs to the running world: it is dropped when the world closes and is never saved, so a task never leaks into the next world and a mod that must outlive a restart writes its deadline to its own storage and schedules again when the world opens.
Transparent screen backgrounds and landing changes
Hooks::screen_transparent_background runs immediately before an in-game screen's
translucent gradient. Returning Flow::Cancel suppresses that gradient, not the
screen's contents. Its batch is a DrawContext: custom drawing survives
cancellation. ClientHost::apply_screen_blur() requests the normal menu blur
at most once per frame; menu_blur_radius() reads the configured radius.
DrawContext::draw_menu_background and tiled_sprite draw menu textures through
the same atlas. The whole-screen screen_render_pre remains a separate moment.
Hooks::entity_change_block_pre can cancel a player's landing block change before
its neighbour notifications and level events. The resident write is restored
before those consumers run, so a crop above cancelled farmland stays untouched.
The event identifies the player, position and old/new block names. This veto is
separate from entity_change_block, the existing post-change mob notification,
and from farmland_trample, the farmland-specific in-write event.
native_stateful_block_entity declares static factory ownership; pure native_block_entity_from_state receives the borrowed actual block/property map. An empty result creates no new entity and never falls through to API15. LevelChunk mutation creates inline after onPlace; existing same-block entities survive state property changes. Bundles need "hooks": @API:state_block_entity_factory@.
Custom world geometry
Hooks::submit_custom_geometry runs on the client after particles, before rendering. Its
WorldRenderView holds camera, partial tick and game time; WorldDrawContext::lines and quads
accept unlit world-space ARGB geometry with depth testing or through walls. Batches fail atomically
for invalid inputs or overflow. usage() reports shared limits (4,096 of each by default);
output clears every view. Submit extracted state, without world writes or resource loading.
WorldDrawContext::draw_model_instances draws one batch of a resolved ModelRef. Each
ModelInstance carries an f64 world origin, a row-major 3×4 local transform, an 0xAARRGGBB
tint (the same convention as WorldQuad::color) and LightCoords block and sky light, each
0 to 15, in vanilla's own convention. The engine converts tint and light to the frame format;
mods never assume that format or supply a packed light word. The view holds 16,384 instances,
counted by usage().instances_used against instances_capacity. A refused batch draws nothing.
A model with a translucent face returns InstanceError::Translucent and is not drawn as solid.
Copies outside the frustum are omitted and do not count. Shader packs and any route that cannot
take GPU instances bake the same prepared geometry on the CPU instead of dropping the model; a
shaders/ pack shades those copies as block entities, as it shades BlockEntityDrawContext::model.
NeoForge counterparts
Each hook's neoforge = ... key in crates/horizon-mod-vocab/src/hooks/ names the NeoForge 26.3 event or method it
matches, since a counterpart's name and contract are the rule for a hook. This page lists the keys as declared.
The book build renders it directly from those declarations; there is no generated Markdown snapshot to update.
A key names the event class (PlayerEvent.Clone) or runtime callback (IPayloadHandler.handle);
notes about cancelling or the event bus belong in the hook's /// doc, not in the key.
658 hooks: 388 name a counterpart, 259 say they have none.
This is a declaration reference, not an independent verification of every mapping. Registration events and the
callbacks they register are different moments: packet receipt names IPayloadHandler.handle, and per-draw item
decoration names IItemDecorator.render, not their registration events. A none explains why no event or method
matches that hook's runtime contract.
Hooks with a counterpart
| Hook | Side | NeoForge |
|---|---|---|
add_attribute_tooltips | client | AddAttributeTooltipsEvent |
add_client_reload_listeners | client | AddClientReloadListenersEvent |
add_pack_finders | both | AddPackFindersEvent |
add_section_geometry | client | AddSectionGeometryEvent |
add_server_reload_listeners | server | AddServerReloadListenersEvent |
alter_ground | server | AlterGroundEvent |
animal_tame | server | AnimalTameEvent |
anvil_craft_post | server | AnvilCraftEvent.Post |
anvil_craft_pre | server | AnvilCraftEvent.Pre |
anvil_update | both | AnvilUpdateEvent |
arm_pose_transform | client | HumanoidModel.ArmPose |
armor_hurt | server | ArmorHurtEvent |
arrow_loose | server | ArrowLooseEvent |
arrow_nock | server | ArrowNockEvent |
attack_entity | server | AttackEntityEvent |
baby_entity_spawn | server | BabyEntitySpawnEvent |
block_drops | server | BlockDropsEvent |
block_entity_container | both | RegisterCapabilitiesEvent |
block_entity_energy | server | Capabilities.Energy.BLOCK |
block_entity_fluids | server | RegisterCapabilitiesEvent |
block_entity_render_bounding_box | client | IBlockEntityRendererExtension.getRenderBoundingBox |
block_entity_submit | client | EntityRenderersEvent.RegisterRenderers |
block_entity_type_add_blocks | both | BlockEntityTypeAddBlocksEvent |
block_grow_feature | server | BlockGrowFeatureEvent |
block_highlight | client | ExtractBlockOutlineRenderStateEvent |
block_screen_effect_draw | client | CustomBlockScreenEffectRenderer |
block_tool_modification | both | BlockEvent.BlockToolModificationEvent |
blocks | both | RegisterEvent |
bonemeal | both | BonemealEvent |
break_block | server | BreakBlockEvent |
build_creative_mode_tab_contents | client | BuildCreativeModeTabContentsEvent |
calculate_player_turn | client | CalculatePlayerTurnEvent |
camera_distance | client | CalculateDetachedCameraDistanceEvent |
camera_pose | client | ViewportEvent.ComputeCameraAngles |
camera_shake | client | ViewportEvent.ComputeCameraAngles |
can_continue_sleeping | server | CanContinueSleepingEvent |
can_harvest_block | both | IBlockExtension.canHarvestBlock |
can_perform_action | both | IItemExtension.canPerformAction |
can_player_sleep | server | CanPlayerSleepEvent |
can_sustain_plant | server | IBlockExtension.canSustainPlant |
chunk_load | server | ChunkEvent.Load |
chunk_sent | server | ChunkWatchEvent.Sent |
chunk_ticket_level_updated | server | ChunkTicketLevelUpdatedEvent |
chunk_unload | server | ChunkEvent.Unload |
chunk_unwatch | server | ChunkWatchEvent.UnWatch |
chunk_watch | server | ChunkWatchEvent.Watch |
client_chat | client | ClientChatEvent |
client_chat_received | client | ClientChatReceivedEvent, ClientChatReceivedEvent.System |
client_disconnecting | client | ClientPlayerNetworkEvent.LoggingOut |
client_information_updated | server | ClientInformationUpdatedEvent |
client_level_load | client | LevelEvent.Load |
client_level_unload | client | LevelEvent.Unload |
client_pause_change_post | client | ClientPauseChangeEvent.Post |
client_pause_change_pre | client | ClientPauseChangeEvent.Pre |
client_player_change_game_type | client | ClientPlayerChangeGameTypeEvent |
client_player_clone | client | ClientPlayerNetworkEvent.Clone |
client_player_logging_in | client | ClientPlayerNetworkEvent.LoggingIn |
client_player_logging_out | client | ClientPlayerNetworkEvent.LoggingOut |
client_resources_reloaded | client | ClientResourceLoadFinishedEvent |
client_setup | client | FMLClientSetupEvent |
client_stopped | client | ClientStoppedEvent |
client_stopping | client | ClientStoppingEvent |
client_tick_post | client | ClientTickEvent.Post |
client_tick_pre | client | ClientTickEvent.Pre |
clone_item_stack | server | IBlockExtension.getCloneItemStack |
component_types | both | RegisterEvent |
compute_fog_color | client | ViewportEvent.ComputeFogColor |
compute_fov | client | ViewportEvent.ComputeFov |
compute_fov_modifier | client | ComputeFovModifierEvent |
config_loading | both | ModConfigEvent.Loading |
config_reloading | both | ModConfigEvent.Reloading |
config_unloading | server | ModConfigEvent.Unloading |
connects_to | server | IBlockExtension.canBeConnectedTo |
crafting_remainder | server | IItemExtension.getCraftingRemainder(ItemStack) |
create_fluid_source | server | CreateFluidSourceEvent |
create_spawn_position | server | LevelEvent.CreateSpawnPosition |
critical_hit | server | CriticalHitEvent |
crop_grow_post | server | CropGrowEvent.Post |
crop_grow_pre | server | CropGrowEvent.Pre |
custom_click_action | server | CustomClickActionEvent |
custom_spawner_tick | server | ModifyCustomSpawnersEvent |
customize_boss_event_progress | client | CustomizeGuiOverlayEvent.BossEventProgress |
customize_chat_overlay | client | CustomizeGuiOverlayEvent.Chat |
damage_item | server | IItemExtension.damageItem |
data_map_types | server | RegisterDataMapTypesEvent |
data_maps_updated | both | DataMapsUpdatedEvent |
data_pack_files | server | AddPackFindersEvent |
datapack_sync | server | OnDatapackSyncEvent |
debug_entry_lines | client | RegisterDebugEntriesEvent |
difficulty_changed | server | DifficultyChangeEvent |
effect_particle_modification | server | EffectParticleModificationEvent |
enchant_effect | server | RegisterEvent |
enchanted_block_loot | server | EnchantedBlockLootEvent |
enchanted_entity_loot | server | EnchantedEntityLootEvent |
enchantment_level_set | server | EnchantmentLevelSetEvent |
ender_entity_teleport | server | EntityTeleportEvent.EnderEntity |
ender_pearl_teleport | server | EntityTeleportEvent.EnderPearl |
enderman_anger | server | EndermanAngerEvent |
entity_constructing | both | EntityEvent.EntityConstructing |
entity_interact | server | PlayerInteractEvent.EntityInteract |
entity_interact_specific | server | PlayerInteractEvent.EntityInteractSpecific |
entity_invulnerability_check | server | EntityInvulnerabilityCheckEvent |
entity_join_level | server | EntityJoinLevelEvent |
entity_leave_level | server | EntityLeaveLevelEvent |
entity_mob_griefing | server | EntityMobGriefingEvent |
entity_mount | server | EntityMountEvent |
entity_place | server | BlockEvent.EntityPlaceEvent, BlockEvent.EntityMultiPlaceEvent |
entity_size | both | EntityEvent.Size |
entity_struck_by_lightning | server | EntityStruckByLightningEvent |
entity_tick_post | server | EntityTickEvent.Post |
entity_tick_pre | server | EntityTickEvent.Pre |
entity_travel_to_dimension | server | EntityTravelToDimensionEvent |
exp_drop | server | IBlockExtension.getExpDrop |
explosion_detonate | server | ExplosionEvent.Detonate |
explosion_knockback | server | ExplosionKnockbackEvent |
explosion_start | server | ExplosionEvent.Start |
extend_poi_types | server | ExtendPoiTypesEvent |
fall_on | server | LivingFallEvent |
farmland_trample | server | BlockEvent.FarmlandTrampleEvent |
finalize_spawn | server | FinalizeSpawnEvent |
fire_odds | server | IBlockExtension.getFlammability, IBlockExtension.getFireSpreadSpeed |
flip_frame | client | FlipFrameEvent |
fluid_place_block | server | BlockEvent.FluidPlaceBlockEvent |
fluid_tooltip | client | FluidTooltipEvent |
game_rule_changed | server | GameRuleChangedEvent |
game_shutting_down | both | GameShuttingDownEvent |
gather_effect_screen_tooltips | client | GatherEffectScreenTooltipsEvent |
gather_skipped_attribute_tooltips | client | GatherSkippedAttributeTooltipsEvent |
get_arm_pose | client | IClientItemExtensions.getArmPose |
get_enchantment_level | both | GetEnchantmentLevelEvent |
get_equipment_drop_chance | server | GetEquipmentDropChanceEvent |
grindstone_place | server | GrindstoneEvent.OnPlaceItem |
grindstone_take | server | GrindstoneEvent.OnTakeItem |
gui_layer_post | client | RenderGuiLayerEvent.Post |
gui_layer_pre | client | RenderGuiLayerEvent.Pre |
gui_render_post | client | RenderGuiEvent.Post |
hand_drawn | client | RenderHandEvent |
health_hud | client | RenderGuiLayerEvent.Pre |
ingredient | both | ICustomIngredient |
ingredient_test | both | ICustomIngredient |
input_key | client | InputEvent.Key |
interaction_key_mapping_triggered | client | InputEvent.InteractionKeyMappingTriggered |
is_ladder | both | IBlockExtension.isLadder |
item_attribute_modifier | both | ItemAttributeModifierEvent |
item_consumption_teleport | server | EntityTeleportEvent.ItemConsumption |
item_container | both | Capabilities.Item.ITEM |
item_crafted | server | PlayerEvent.ItemCraftedEvent |
item_decorations | client | IItemDecorator.render |
item_expire | server | ItemExpireEvent |
item_fished | server | ItemFishedEvent |
item_model_property | client | RegisterConditionalItemModelPropertyEvent |
item_model_range_property | client | RegisterRangeSelectItemModelPropertyEvent |
item_model_select_property | client | RegisterSelectItemModelPropertyEvent |
item_picked_up | server | ItemEntityPickupEvent.Pre |
item_smelted | server | PlayerEvent.ItemSmeltedEvent |
item_stacked_on_other | both | ItemStackedOnOtherEvent |
item_tooltip | client | ItemTooltipEvent |
item_toss | server | ItemTossEvent |
items | both | RegisterEvent |
key_mapping_pre | client | InputEvent.InteractionKeyMappingTriggered, InputEvent.Key |
left_click_block | server | PlayerInteractEvent.LeftClickBlock |
left_click_empty | client | PlayerInteractEvent.LeftClickEmpty |
level_load | server | LevelEvent.Load |
level_save | server | LevelEvent.Save |
level_tick_post | both | LevelTickEvent.Post |
level_tick_pre | both | LevelTickEvent.Pre |
level_unload | server | LevelEvent.Unload |
living_breathe | server | LivingBreatheEvent |
living_change_target | server | LivingChangeTargetEvent |
living_conversion_post | server | LivingConversionEvent.Post |
living_conversion_pre | server | LivingConversionEvent.Pre |
living_damage_post | server | LivingDamageEvent.Post |
living_damage_pre | server | LivingDamageEvent.Pre |
living_death | server | LivingDeathEvent |
living_destroy_block | server | LivingDestroyBlockEvent |
living_drops | server | LivingDropsEvent |
living_drown | server | LivingDrownEvent |
living_entity_use_item_finish | server | LivingEntityUseItemEvent.Finish |
living_entity_use_item_start | both | LivingEntityUseItemEvent.Start |
living_entity_use_item_stop | both | LivingEntityUseItemEvent.Stop |
living_entity_use_item_tick | both | LivingEntityUseItemEvent.Tick |
living_equipment_change | server | LivingEquipmentChangeEvent |
living_experience_drop | server | LivingExperienceDropEvent |
living_fall | both | LivingFallEvent |
living_get_projectile | both | LivingGetProjectileEvent |
living_heal | both | LivingHealEvent |
living_incoming_damage | server | LivingIncomingDamageEvent |
living_jump | both | LivingEvent.LivingJumpEvent |
living_knockback | server | LivingKnockBackEvent |
living_shield_block | server | LivingShieldBlockEvent |
living_use_totem | server | LivingUseTotemEvent |
living_visibility | server | LivingEvent.LivingVisibilityEvent |
loot_condition | server | RegisterEvent |
loot_drops | server | LivingDropsEvent, BlockDropsEvent |
loot_function | server | RegisterEvent |
loot_table_load | server | LootTableLoadEvent |
map_decoration_types | both | RegisterEvent |
map_decorations | client | MapDecorationType |
may_place_on | server | IBlockExtension.canSustainPlant |
menu_closed | server | PlayerContainerEvent.Close |
menu_extra_data | server | IPlayerExtension.openMenu |
mob_despawn | server | MobDespawnEvent |
mob_effect_added | server | MobEffectEvent.Added |
mob_effect_applicable | server | MobEffectEvent.Applicable |
mob_effect_expired | server | MobEffectEvent.Expired |
mob_effect_remove | server | MobEffectEvent.Remove |
mob_killed | server | LivingDeathEvent |
mob_layer | client | EntityRenderersEvent.AddLayers |
mob_spawn_placement_check | server | MobSpawnEvent.SpawnPlacementCheck |
mob_spawn_position_check | server | MobSpawnEvent.PositionCheck |
mob_spawned | server | EntityJoinLevelEvent, FinalizeSpawnEvent |
mob_split | server | MobSplitEvent |
mob_submit | client | RenderLivingEvent.Pre |
mod_mismatch | both | ModMismatchEvent |
model_baking_completed | client | ModelEvent.BakingCompleted |
modify_custom_spawners | server | ModifyCustomSpawnersEvent |
modify_default_components | both | ModifyDefaultComponentsEvent |
mouse_button_post | client | InputEvent.MouseButton.Post |
mouse_button_pre | client | InputEvent.MouseButton.Pre |
mouse_scrolling | client | InputEvent.MouseScrollingEvent |
movement_input | client | MovementInputUpdateEvent |
music | client | SelectMusicEvent |
neighbor_notify | server | BlockEvent.NeighborNotifyEvent |
net_from_client | server | IPayloadHandler.handle |
net_from_server | client | IPayloadHandler.handle |
note_block_change | server | NoteBlockEvent.Change |
note_block_play | server | NoteBlockEvent.Play |
on_block_exploded | server | IBlockExtension.onBlockExploded |
on_entity_item_update | server | IItemExtension.onEntityItemUpdate |
on_entity_swing | both | IItemExtension.onEntitySwing |
on_equip | server | LivingEquipmentChangeEvent |
on_item_use_first | server | IItemExtension.onItemUseFirst |
on_left_click_entity | server | IItemExtension.onLeftClickEntity |
on_neighbor_change | server | IBlockExtension.onNeighborChange |
on_use_tick | both | LivingEntityUseItemEvent.Tick |
permissions_changed | server | PermissionsChangedEvent |
pickup_xp | server | PlayerXpEvent.PickupXp |
piston_post | server | PistonEvent.Post |
piston_pre | server | PistonEvent.Pre |
play_level_sound_at_entity | both | PlayLevelSoundEvent.AtEntity |
play_level_sound_at_position | both | PlayLevelSoundEvent.AtPosition |
play_sound_source | client | PlaySoundEvent |
play_streaming_source | client | PlayStreamingSourceEvent |
player_advancement_done | server | AdvancementEvent.AdvancementEarnEvent |
player_advancement_progress | server | AdvancementEvent.AdvancementProgressEvent |
player_bed_leave | server | PlayerWakeUpEvent |
player_break_speed | both | PlayerEvent.BreakSpeed |
player_brewed_potion | server | PlayerBrewedPotionEvent |
player_changed_dimension | server | PlayerEvent.PlayerChangedDimensionEvent |
player_clone | server | PlayerEvent.Clone (not cancellable) |
player_command | server | CommandEvent |
player_container_close | server | PlayerContainerEvent.Close |
player_container_open | server | PlayerContainerEvent.Open |
player_died | server | LivingDeathEvent |
player_enchant_item | server | PlayerEnchantItemEvent |
player_flyable_fall | server | PlayerFlyableFallEvent |
player_game_mode_changed | server | PlayerEvent.PlayerChangeGameModeEvent |
player_harvest_check | both | PlayerEvent.HarvestCheck |
player_heart_type | client | PlayerHeartTypeEvent |
player_item_smithed | server | PlayerEvent.ItemSmithedEvent |
player_load_from_file | server | PlayerEvent.LoadFromFile (not cancellable) |
player_logged_in | server | PlayerEvent.PlayerLoggedInEvent |
player_logged_out | server | PlayerEvent.PlayerLoggedOutEvent |
player_name_format | both | PlayerEvent.NameFormat |
player_respawn_position | server | PlayerRespawnPositionEvent |
player_respawned | server | PlayerEvent.PlayerRespawnEvent |
player_save_to_file | server | PlayerEvent.SaveToFile (not cancellable) |
player_set_spawn | server | PlayerSetSpawnEvent |
player_spawn_phantoms | server | PlayerSpawnPhantomsEvent |
player_start_tracking | server | PlayerEvent.StartTracking |
player_stat_award | server | StatAwardEvent |
player_stop_tracking | server | PlayerEvent.StopTracking |
player_submit | client | RenderPlayerEvent.Pre |
player_swap_hands | server | LivingSwapItemsEvent.Hands |
player_switch_hotbar_slot_post | server | PlayerSwitchHotbarSlotEvent.Post |
player_switch_hotbar_slot_pre | server | PlayerSwitchHotbarSlotEvent.Pre |
player_tab_list_name_format | server | PlayerEvent.TabListNameFormat |
player_tick_post | both | PlayerTickEvent.Post |
player_tick_pre | both | PlayerTickEvent.Pre |
player_xp_change | server | PlayerXpEvent.XpChange |
player_xp_level_change | server | PlayerXpEvent.LevelChange |
portal_spawn | server | BlockEvent.PortalSpawnEvent |
potential_spawns | server | LevelEvent.PotentialSpawns |
potion_brew_post | server | PotionBrewEvent.Post |
potion_brew_pre | server | PotionBrewEvent.Pre |
projectile_impact | server | ProjectileImpactEvent |
recipe_property_sets | both | RegisterRecipePropertiesEvent |
recipes_received | client | RecipesReceivedEvent |
register_block_tint_sources | client | RegisterColorHandlersEvent.BlockTintSources |
register_cauldron_interaction | both | RegisterCauldronInteractionEvent |
register_client_commands | client | RegisterClientCommandsEvent |
register_color_resolvers | client | RegisterColorHandlersEvent.ColorResolvers |
register_commands | server | RegisterCommandsEvent |
register_configs | both | ModContainer.registerConfig |
register_debug_entries | client | RegisterDebugEntriesEvent |
register_debug_renderers | client | RegisterDebugRenderersEvent |
register_dimension_transition_screen | client | RegisterDimensionTransitionScreenEvent |
register_entity_spectator_shaders | client | RegisterEntitySpectatorShadersEvent |
register_fluid_models | client | RegisterFluidModelsEvent |
register_game_tests | server | RegisterGameTestsEvent |
register_gui_layers | client | RegisterGuiLayersEvent |
register_item_models | client | RegisterItemModelsEvent |
register_item_tint_sources | client | RegisterColorHandlersEvent.ItemTintSources |
register_layer_definitions | client | EntityRenderersEvent.RegisterLayerDefinitions |
register_model_loaders | client | ModelEvent.RegisterLoaders |
register_particle_groups | client | RegisterParticleGroupsEvent |
register_particle_providers | client | RegisterParticleProvidersEvent |
register_preset_editors | client | RegisterPresetEditorsEvent |
register_range_select_item_model_property | client | RegisterRangeSelectItemModelPropertyEvent |
register_render_pipelines | client | RegisterRenderPipelinesEvent |
register_sprite_sources | client | RegisterSpriteSourcesEvent |
register_texture_atlases | client | RegisterTextureAtlasesEvent |
release_using | both | LivingEntityUseItemEvent.Stop |
render_arm | client | RenderArmEvent |
render_fog | client | ViewportEvent.RenderFog |
render_frame_post | client | RenderFrameEvent.Post |
render_frame_pre | client | RenderFrameEvent.Pre |
render_gui_post | client | RenderGuiEvent.Post |
render_gui_pre | client | RenderGuiEvent.Pre |
render_hand | client | RenderHandEvent |
render_item_in_frame | client | RenderItemInFrameEvent |
render_level_stage | client | RenderLevelStageEvent |
render_name_tag | client | RenderNameTagEvent |
render_tooltip_gather_components | client | RenderTooltipEvent.GatherComponents |
render_tooltip_pre | client | RenderTooltipEvent.Pre |
render_tooltip_texture | client | RenderTooltipEvent.Texture |
right_click_block | both | PlayerInteractEvent.RightClickBlock |
right_click_empty | client | PlayerInteractEvent.RightClickEmpty |
right_click_item | both | PlayerInteractEvent.RightClickItem |
screen_char_typed_post | client | ScreenEvent.CharacterTyped.Post |
screen_char_typed_pre | client | ScreenEvent.CharacterTyped.Pre |
screen_closing | client | ScreenEvent.Closing |
screen_init_post | client | ScreenEvent.Init.Post |
screen_key_pressed_post | client | ScreenEvent.KeyPressed.Post |
screen_key_pressed_pre | client | ScreenEvent.KeyPressed.Pre |
screen_key_released_post | client | ScreenEvent.KeyReleased.Post |
screen_key_released_pre | client | ScreenEvent.KeyReleased.Pre |
screen_mouse_button_pressed_post | client | ScreenEvent.MouseButtonPressed.Post |
screen_mouse_button_pressed_pre | client | ScreenEvent.MouseButtonPressed.Pre |
screen_mouse_button_released_post | client | ScreenEvent.MouseButtonReleased.Post |
screen_mouse_button_released_pre | client | ScreenEvent.MouseButtonReleased.Pre |
screen_mouse_dragged_post | client | ScreenEvent.MouseDragged.Post |
screen_mouse_dragged_pre | client | ScreenEvent.MouseDragged.Pre |
screen_mouse_scrolled_post | client | ScreenEvent.MouseScrolled.Post |
screen_mouse_scrolled_pre | client | ScreenEvent.MouseScrolled.Pre |
screen_opening | client | ScreenEvent.Opening |
screen_preedit_post | client | ScreenEvent.Preedit.Post |
screen_preedit_pre | client | ScreenEvent.Preedit.Pre |
screen_render_background | client | ScreenEvent.Render.Background |
screen_render_foreground | client | ScreenEvent.Render.Foreground |
screen_render_inventory_mob_effects | client | ScreenEvent.RenderInventoryMobEffects |
screen_render_post | client | ScreenEvent.Render.Post |
screen_render_pre | client | ScreenEvent.Render.Pre |
screen_transparent_background | client | ScreenEvent.Render.Pre (transparent background) |
screenshot | client | ScreenshotEvent |
server_about_to_start | server | ServerAboutToStartEvent |
server_chat | server | ServerChatEvent |
server_started | server | ServerStartedEvent |
server_starting | server | ServerStartingEvent |
server_stopped | server | ServerStoppedEvent |
server_stopping | server | ServerStoppingEvent |
server_tick | server | ServerTickEvent.Pre, ServerTickEvent.Post |
setting_changed | client | ModConfigEvent.Reloading (a ModConfigSpec value set on the config screen) |
should_check_weak_power | server | IBlockExtension.shouldCheckWeakPower |
sleep_finished_time | server | SleepFinishedTimeEvent |
sound_engine_load | client | SoundEngineLoadEvent |
spread_players_teleport | server | EntityTeleportEvent.SpreadPlayersCommand |
standalone_models | client | ModelEvent.RegisterStandalone |
submit_custom_geometry | client | SubmitCustomGeometryEvent |
sweep_attack | both | SweepAttackEvent |
tags_updated | both | TagsUpdatedEvent |
teleport_command | server | EntityTeleportEvent.TeleportCommand |
texture_atlas_stitched | client | TextureAtlasStitchedEvent |
toast_add | client | ToastAddEvent |
tooltip_component | client | RegisterClientTooltipComponentFactoriesEvent |
tooltip_component_draw | client | RegisterClientTooltipComponentFactoriesEvent |
trade_with_villager | server | TradeWithVillagerEvent |
use_item | both | PlayerInteractEvent.RightClickItem |
use_item_on_block | both | UseItemOnBlockEvent |
use_on | server | UseItemOnBlockEvent |
validate_tickets | server | LoadingValidationCallback |
vanilla_game_event | server | VanillaGameEvent |
village_siege | server | VillageSiegeEvent |
weak_changes | server | IBlockExtension.getWeakChanges |
window_resize | client | WindowResizeEvent |
world_drawn | client | RenderLevelStageEvent.AfterLevel |
world_lifecycle | server | ServerStartingEvent, ServerStartedEvent, ServerStoppingEvent, ServerStoppedEvent |
xp_orb_targeting | server | XpOrbTargetingEvent |
Hooks that declare no counterpart
A hook with no NeoForge counterpart is judged on its merits like any other. The note is the key's own reason.
| Hook | Side | Reason |
|---|---|---|
affect_neighbors_after_removal | server | vanilla Block.affectNeighborsAfterRemoval; NeoForge adds no event or extension for it |
analog_output | server | vanilla BlockBehaviour.getAnalogOutputSignal; NeoForge adds no event or extension for it |
anvil_damaged | server | NeoForge has no such event |
apply_effect_tick | server | vanilla MobEffect.applyEffectTick; NeoForge adds no event or extension for it |
armor_stand_manipulate | server | Bukkit's PlayerArmorStandManipulateEvent |
bake_model_loader | client | vanilla UnbakedGeometry.bake of a loader ModelEvent.RegisterLoaders registers; NeoForge adds no event for it |
beacon_state_change | server | NeoForge has no such event |
bell_ring | server | NeoForge has no such event; Bukkit's BellRingEvent |
biome_colors | client | NeoForge adds ColorResolvers with RegisterColorHandlersEvent.ColorResolvers; it does not retint a biome's own colours |
block_break_progress_update | server | NeoForge has no such event |
block_burn | server | NeoForge has no such event; Bukkit's BlockBurnEvent |
block_changed | server | NeoForge has no set-block event; IBlockExtension.onBlockStateChange runs after the change and may not modify the level |
block_cook | server | NeoForge has no such event |
block_damage_abort | server | NeoForge has no such event; Bukkit's BlockDamageAbortEvent |
block_dispense_armor | server | NeoForge has no such event |
block_entity | both | vanilla EntityBlock.newBlockEntity; NeoForge adds no event or extension for it |
block_entity_tick | server | vanilla BlockEntityTicker; NeoForge adds no event or extension for it |
block_entity_ticks | server | vanilla EntityBlock.getTicker; NeoForge adds no event or extension for it |
block_entity_update_tag | server | vanilla BlockEntity.getUpdateTag; NeoForge adds no event or extension for it); IBlockEntityExtension.handleUpdateTag is the client half |
block_entity_view_distance | client | vanilla BlockEntityRenderer.getViewDistance; NeoForge adds no event or extension for it |
block_fade | server | NeoForge has no such event |
block_failed_dispense | server | NeoForge has no such event |
block_form | server | NeoForge has no such event |
block_ignite | server | NeoForge has no such event; Bukkit's BlockIgniteEvent |
block_jump_factor | both | vanilla Block.getJumpFactor; NeoForge adds no event or extension for it |
block_lock_check | server | NeoForge has no such event |
block_receive_game | server | NeoForge has no such event |
block_render_visibility | client | Horizon-only; NeoForge has no counterpart |
block_shear_entity | server | NeoForge has no such event |
block_signal | server | vanilla BlockBehaviour.getSignal and getDirectSignal; NeoForge adds no event or extension for it |
block_speed_factor | both | vanilla Block.getSpeedFactor; NeoForge adds no event or extension for it |
block_spread | server | BlockEvent.FluidPlaceBlockEvent covers only fluids making stone and the like, not flow or fire spread |
block_step_on | server | vanilla Block.stepOn; NeoForge adds no event or extension for it |
bounce_on | server | vanilla Block.bounceOn; IBlockExtension.getBounceRestitution only returns a factor |
brewing_complete | server | NeoForge has no such event |
brewing_fuel | server | NeoForge has no such event |
brewing_start | server | NeoForge has no such event |
broadcast_message | server | NeoForge has no such event; Bukkit's BroadcastMessageEvent |
bucket_empty | server | vanilla BucketItem.emptyContents; NeoForge has no matching event |
camera_type_cycle | client | NeoForge has no camera type event |
campfire_start | server | NeoForge has no such event |
can_eat | both | vanilla Player.canEat; NeoForge adds no event or extension for it |
can_survive | server | vanilla BlockBehaviour.canSurvive; NeoForge adds no event or extension for it |
cauldron_level_changed | server | NeoForge has no such event; Bukkit's CauldronLevelChangeEvent |
client_mining_predict | client | client mining prediction |
client_packet | client | NeoForge has no raw play-packet receive event |
client_packet_send | client | NeoForge has no raw play-packet send event |
command | client | ClientChatEvent is for chat messages; client commands are added by RegisterClientCommandsEvent |
compost_item | server | NeoForge has no such event |
consume_effect | server | runtime ConsumeEffect.apply; RegisterEvent registers effect types, not invocations |
container_opened | server | not NeoForge's PlayerContainerEvent.Open: this hook can cancel the opening |
contents_changed | server | Container.setChanged has no bus event |
crafter_craft | server | NeoForge has no such event |
creative_slot | server | vanilla ServerGamePacketListenerImpl.handleSetCreativeModeSlot; NeoForge adds no event or extension for it |
creeper_ignite | server | NeoForge has no such event |
creeper_power | server | NeoForge has no such event |
dispense_item | server | vanilla DispenserBlock.getDispenseMethod; NeoForge adds no event or extension for it |
dispense_method | server | vanilla DispenserBlock.getDispenseMethod; NeoForge adds no event or extension for it |
dragon_egg_form | server | NeoForge has no such event |
emissive | client | Horizon-only; NeoForge has no counterpart |
enchant_item | server | NeoForge has no such event |
entities_load | server | NeoForge has no such event |
entities_unload | server | NeoForge has no such event |
entity_break_door | server | NeoForge has no such event |
entity_breed | server | NeoForge has no such event |
entity_change_block | server | NeoForge has no such event |
entity_change_block_pre | server | NeoForge has no such event; Bukkit's EntityChangeBlockEvent is cancellable |
entity_drop_item | server | NeoForge has no such event; Bukkit's EntityDropItemEvent |
entity_enter_block | server | NeoForge has no such event |
entity_enter_love_mode | server | NeoForge has no such event |
entity_inside | server | vanilla Block.entityInside; NeoForge adds no event or extension for it |
entity_outline | client | NeoForge 26.3 has no glowing or team colour override |
entity_portal | server | NeoForge has no such event |
entity_pose_change | server | NeoForge has no such event |
entity_shoot_bow | server | NeoForge has no such event |
entity_tame | server | NeoForge has no such event |
entity_teleport | server | NeoForge has no such event |
entity_tick_listened | server | Horizon-only; NeoForge has no counterpart); an opt-in gate for a hook |
entity_toggle_glide | server | NeoForge has no such event |
entity_unleash | server | NeoForge has no such event; Bukkit's EntityUnleashEvent |
exp_bottle | server | NeoForge has no such event |
experience_orb_can_merge | server | vanilla ExperienceOrb.canMerge; NeoForge adds no event or extension for it |
experience_orb_merge | server | vanilla ExperienceOrb.tryMergeToExisting; NeoForge adds no event or extension for it |
explosion_prime | server | NeoForge has no such event; Bukkit's ExplosionPrimeEvent |
fill_bucket | server | NeoForge 26.3 has no bucket-fill event |
firework_explode | server | NeoForge has no such event |
fluid_flow | both | vanilla FlowingFluid.getFlow; NeoForge adds no event or extension for it |
fluid_level_change | server | NeoForge has no such event |
fluid_rules | server | BaseFlowingFluid.getTickDelay, getDropOff, getSlopeFindDistance and FluidType.canConvertToSource are overrides; NeoForge adds no event |
fluid_tick | server | NeoForge has no such event |
food_level_changed | server | vanilla: FoodData; Bukkit's FoodLevelChangeEvent |
framerate_limit | client | vanilla FramerateLimitTracker.getFramerateLimit fires no event |
furnace_burn | server | NeoForge has no such event |
furnace_extract | server | NeoForge has no such event |
furnace_smelt | server | NeoForge has no such event |
furnace_start_smelt | server | NeoForge has no such event |
hanging_break | server | vanilla hanging entity breaking; NeoForge adds no event or extension for it); AttackEntityEvent fires for players only |
hanging_place | server | vanilla HangingEntityItem.useOn; NeoForge adds no event or extension for it); PlayerInteractEvent.RightClickBlock fires before it |
has_analog_output | server | vanilla BlockBehaviour.hasAnalogOutputSignal; NeoForge adds no event or extension for it |
hears_projectile_impacts | server | Horizon-only; NeoForge has no counterpart); an opt-in gate for a hook |
hopper_inventory_search | server | NeoForge has no such event; Bukkit's InventoryMoveItemEvent |
hopper_pickup_item | server | NeoForge has no direct equivalent; Bukkit's InventoryPickupItemEvent |
hurt_enemy | server | vanilla Item.hurtEnemy; NeoForge adds no event or extension for it); LivingDamageEvent.Post comes later |
inventory_block_start | server | NeoForge has no such event |
inventory_move_item | server | NeoForge has no such event; Bukkit's InventoryMoveItemEvent |
inventory_tick | server | vanilla Item.inventoryTick; NeoForge adds no event or extension for it); PlayerTickEvent ticks the player, not each stack |
is_bonemeal_success | server | vanilla BonemealableBlock.isBonemealSuccess; NeoForge adds no event or extension for it |
is_pathfindable | server | vanilla BlockBehaviour.isPathfindable; NeoForge 26.3 has no IBlockExtension.isPathfindable, IBlockExtension.getBlockPathType answers a different question |
is_signal_source | server | vanilla BlockBehaviour.isSignalSource; NeoForge adds no event or extension for it |
is_valid_bonemeal_target | both | vanilla BonemealableBlock.isValidBonemealTarget; NeoForge adds no event or extension for it); BonemealEvent fires when meal is used, before the block's contract |
item_after_vertical_collision | both | Block.updateEntityAfterFallOn; dropped-item contact |
item_bar | client | vanilla Item.isBarVisible/getBarWidth/getBarColor; RegisterItemDecorationsEvent registers decorators, not bar queries |
item_despawn | server | NeoForge has no such event; Bukkit's ItemDespawnEvent |
item_interact_mob | server | Item.interactLivingEntity |
item_model_update | client | vanilla ItemModel.update of a type RegisterItemModelsEvent registers; NeoForge adds no event for it |
item_spawn | server | NeoForge has no such event |
leaves_decay | server | NeoForge has no such event |
lightning_strike | server | NeoForge has no such event |
living_death_post | server | LivingDeathEvent fires before the death |
living_equipment_listened | server | Horizon-only; NeoForge has no counterpart); an opt-in gate for a hook |
living_flying_speed | both | vanilla LivingEntity.getFlyingSpeed; NeoForge adds no event or extension for it |
living_jump_power | both | vanilla LivingEntity.getJumpPower; not LivingEvent.LivingJumpEvent |
living_speed | both | vanilla LivingEntity.getSpeed; NeoForge adds no event or extension for it |
mending_repair | server | NeoForge only scales a repair with IItemExtension.getXpRepairRatio |
menu_background | client | NeoForge has no menu blur event |
menu_button | server | NeoForge has no such event; vanilla AbstractContainerMenu.clickMenuButton |
menu_clicked | server | vanilla AbstractContainerMenu.clicked; NeoForge adds no event or extension for it |
menu_quick_move | server | a Java menu overrides AbstractContainerMenu.quickMoveStack; no event |
menu_slot_rules | both | vanilla Slot.mayPlace and mayPickup; NeoForge adds no event or extension for it |
minecart_pass | server | Forge's IBaseRailExtension.onMinecartPass is gone in NeoForge 26.3 |
minimap | client | Horizon-only; NeoForge has no counterpart |
mod_container_viewers | server | vanilla ContainerOpenersCounter; NeoForge adds no event or extension for it); PlayerContainerEvent.Open and Close fire per player |
moisture_change | server | NeoForge has no such event |
music_layers | client | SelectMusicEvent swaps the one track; NeoForge has no layered music |
neighbor_changed | server | vanilla Block.neighborChanged; NeoForge adds no event or extension for it); BlockEvent.NeighborNotifyEvent is the neighbour broadcast, a different point |
on_effect_added | server | vanilla MobEffect.onEffectAdded; NeoForge adds no event or extension for it |
panel_event | client | Horizon-only; NeoForge has no counterpart |
perform_bonemeal | server | vanilla BonemealableBlock.performBonemeal; NeoForge adds no event or extension for it |
player_arm_swing | server | NeoForge has no such event; Bukkit's PlayerAnimationEvent |
player_attack_cooldown_reset | server | NeoForge has no such event |
player_bed_enter | server | NeoForge has no such event; Bukkit's PlayerBedEnterEvent |
player_bucket_entity | server | NeoForge has no such event |
player_bundle_item_select | server | NeoForge has no such event; Bukkit's PlayerBundleItemSelect |
player_change_beacon_effect | server | NeoForge has no such event |
player_client_loaded_world | server | NeoForge has no such event; Bukkit's PlayerClientLoadedWorld |
player_commands_sent | server | NeoForge 26.3 has no such event |
player_deep_sleep | server | NeoForge has no such event |
player_destroy_item | server | NeoForge's PlayerDestroyItemEvent carries the original stack and a hand, which armour has not; this hook only listens and names the slot instead. Bukkit's PlayerItemBreakEvent |
player_edit_book | server | NeoForge has no such event; Bukkit's PlayerEditBookEvent |
player_egg_throw | server | NeoForge has no such event |
player_elytra_boost | server | NeoForge has no such event |
player_exp_cooldown_change | server | NeoForge has no such event |
player_fish | server | NeoForge's ItemFishedEvent fires after the loot roll, in FishingHook.retrieve; Bukkit's PlayerFishEvent |
player_flower_pot_manipulate | server | NeoForge has no such event |
player_input | server | NeoForge has no such event; Bukkit's PlayerInputEvent |
player_insert_lectern_book | server | NeoForge has no such event |
player_item_cooldown | server | NeoForge has no such event; Paper's PlayerItemCooldownEvent |
player_item_frame_change | server | NeoForge has no such event |
player_item_mend | server | NeoForge has no such event |
player_jump | server | NeoForge has no such event; Bukkit's PlayerJumpEvent |
player_kicked | server | NeoForge has no such event; Bukkit's PlayerKickEvent |
player_launch_projectile | server | NeoForge has no such event; Bukkit's PlayerLaunchProjectile |
player_lectern_page_change | server | NeoForge has no such event |
player_level_change | server | NeoForge has no such event; Bukkit's PlayerLevelChangeEvent |
player_login | server | Bukkit's PlayerLoginEvent; NeoForge's PlayerNegotiationEvent is not cancellable |
player_loom_pattern_select | server | NeoForge has no such event |
player_map_filled | server | NeoForge has no such event |
player_name_entity | server | NeoForge has no such event |
player_open_sign | server | NeoForge has no such event |
player_pick_item | server | NeoForge has no such event; Paper's PlayerPickItemEvent |
player_pickup_arrow | server | NeoForge has no such event |
player_ready_arrow | server | NeoForge has no such event; Bukkit's PlayerReadyArrow |
player_recipe_book_click | server | NeoForge has no such event |
player_recipe_book_settings_change | server | NeoForge has no such event; Bukkit's PlayerRecipeBookSettingsChange |
player_recipe_discovered | server | NeoForge has no such event; Bukkit's PlayerRecipeDiscoverEvent |
player_render_listened | client | Horizon-only; NeoForge has no counterpart); an opt-in gate for a hook |
player_riptide | server | NeoForge has no such event; Bukkit's PlayerRiptideEvent |
player_shear_entity | server | NeoForge has no such event; Bukkit's PlayerShearEntityEvent |
player_shield_disable | server | NeoForge has no such event |
player_sprint | server | NeoForge has no sprint event |
player_stays_on_ground_surface | both | vanilla Player.isStayingOnGroundSurface; NeoForge adds no event or extension for it |
player_stonecutter_recipe_select | server | NeoForge has no such event |
player_stop_spectating_entity | server | NeoForge has no such event |
player_stop_using_item | server | NeoForge has no such event; Bukkit's PlayerStopUsingItem |
player_swap_with_equipment_slot | server | NeoForge has no such event |
player_take_lectern_book | server | NeoForge has no such event |
player_teleport_end_gateway | server | NeoForge has no such event |
player_teleported | server | EntityTeleportEvent.TeleportCommand fires before the move and can cancel it; this hook is told after the move |
player_toggle_entity_age_lock | server | NeoForge has no such event |
player_toggle_flight | server | NeoForge has no flight toggle event; Bukkit's PlayerToggleFlightEvent |
player_toggle_sneak | server | NeoForge has no such event; Bukkit's PlayerToggleSneakEvent |
player_totem | server | NeoForge's LivingUseTotemEvent can cancel the save; this hook only listens. Bukkit's EntityResurrectEvent |
player_trade | server | NeoForge has no such event |
player_unleash_entity | server | NeoForge has no such event |
player_velocity | server | NeoForge has no such event |
portal_create | server | NeoForge has no such event |
potion_splash | server | NeoForge has no such event |
pre_remove_side_effects | server | vanilla BlockEntity.preRemoveSideEffects; NeoForge adds no event or extension for it |
prepare_hopper_transfer | server | NeoForge has no hopper event |
prepare_item_craft | server | NeoForge has no such event |
prepare_item_enchant | server | NeoForge has no such event |
prepare_smithing | server | NeoForge has no such event |
projectile_launch | server | NeoForge has no such event |
raid_spawn_wave | server | NeoForge has no such event |
raid_state_change | server | NeoForge has no such event |
random_tick | server | vanilla BlockBehaviour.randomTick; NeoForge adds no event or extension for it |
recipe_book_categories | both | RegisterRecipeBookSearchCategoriesEvent registers search groups only, not a recipe type's categories |
register_runtime_sprites | client | runtime registration before client atlas construction |
remove_block | server | vanilla Level.removeBlock; no NeoForge event covers this call |
render_needs | client | Horizon-only; NeoForge has no counterpart |
resource_pack_status | server | NeoForge has no resource-pack status event |
run_mod_command | server | a command's executor is part of the Brigadier node added in RegisterCommandsEvent; CommandEvent only watches executions |
scheduled_task | server | vanilla MinecraftServer.tell(TickTask); NeoForge adds no event for it |
screen | both | Horizon declares a block's screen through hooks; NeoForge registers a Screen class per menu with RegisterMenuScreensEvent |
screen_click | client | see screen |
screen_draw | client | see screen |
screen_exclusion_areas | client | NeoForge has no counterpart in the game; recipe viewers define their own API |
screen_tooltip | client | see screen |
sculk_bloom | server | NeoForge has no such event |
server_tick_rate_changed | server | vanilla MinecraftServer.onTickRateChanged; NeoForge adds no event or extension for it |
shader_params | client | Horizon-only; NeoForge has no counterpart |
should_apply_effect_tick_this_tick | server | vanilla MobEffect.shouldApplyEffectTickThisTick; NeoForge adds no event or extension for it |
should_redstone_wire_connect_to | server | vanilla BlockBehaviour.shouldRedstoneWireConnectTo; NeoForge adds no event or extension for it |
sign_changed | server | NeoForge has no sign-editing event |
smoke_source | server | vanilla CampfireBlock.isSmokeSource; NeoForge adds no event or extension for it |
spawn_change | server | NeoForge has no such event |
sponge_absorb | server | NeoForge has no such event |
sprite_source | client | vanilla SpriteSource.run of a type RegisterSpriteSourcesEvent registers; NeoForge adds no event for it |
state_for_placement | server | vanilla Block.getStateForPlacement; NeoForge adds no event or extension for it |
stateful_block_entity | both | static factory ownership for vanilla EntityBlock.newBlockEntity |
tab_complete | server | NeoForge has no generic tab completion event |
target_hit | server | NeoForge has no such event |
thunder_change | server | NeoForge has no such event |
tick_block | server | vanilla BlockBehaviour.tick; NeoForge adds no event or extension for it |
tick_target_millis | client | vanilla Minecraft.getTickTargetMillis; NeoForge adds no event or extension for it |
time_skip | server | NeoForge has no such event |
tnt_primed | server | NeoForge has no TNT priming event; the primed TNT entity joins with EntityJoinLevelEvent |
trade_select | server | NeoForge has no such event |
update_shape | server | vanilla BlockBehaviour.updateShape; NeoForge adds no event or extension for it |
use_animation | client | vanilla Item.getUseAnimation; NeoForge adds no event or extension for it |
use_duration | both | vanilla Item.getUseDuration; NeoForge adds no event or extension for it); LivingEntityUseItemEvent.Start can set a use's duration |
use_item_on | server | vanilla BlockBehaviour.useItemOn; PlayerInteractEvent.RightClickBlock cancels or answers before it |
use_without_item | server | vanilla BlockBehaviour.useWithoutItem; PlayerInteractEvent.RightClickBlock cancels or answers before it |
vault_change_state | server | NeoForge has no such event |
vault_loot | server | NeoForge has no such event; Bukkit's BlockDispenseLoot |
vehicle_create | server | NeoForge has no such event; Bukkit's VehicleCreateEvent |
vehicle_damage | server | NeoForge has no such event; Bukkit's VehicleDamageEvent |
vehicle_destroy | server | NeoForge has no such event; Bukkit's VehicleDestroyEvent |
vehicle_moved | server | NeoForge has no vehicle move event; EntityTickEvent covers the tick |
visuals | client | Horizon-only; NeoForge has no counterpart |
weather_change | server | NeoForge has no such event |
whitelist_state_update | server | NeoForge has no such event; Bukkit's WhitelistToggleEvent |
world_border_bounds_change_finish | server | NeoForge has no such event; Bukkit's WorldBorderBoundsChangeFinish |
world_border_bounds_changed | server | NeoForge has no such event |
world_border_center_changed | server | NeoForge has no such event |
world_saved | server | NeoForge has no such event |
world_view_drawn | client | Horizon-only; NeoForge has no counterpart |
world_views | client | Horizon-only; NeoForge has no counterpart |
zoom | client | Horizon-only; ViewportEvent.ComputeFov is the nearest way to change the view |
For agents
Rules for a coding agent making a Horizon mod. They are short on purpose; each links to its page.
- Choose the shape. A content mod has no
Cargo.toml. A code mod hasCargo.tomlandsrc/at the top and implementsHookswith#[mod_api::horizon_mod]. Never write raw injections first. - Use a hook or a Part method when one exists (search the rustdoc of
horizon-mod-api). A per-block, per-item or per-entity moment is a Part method (Parts). - Otherwise follow the injection ladder: an API
[[inject]]declaration naming a crate-root function and itsmod_pointsrecord; useaccess = "internals"and declared internals only when the API cannot express it. Every injection comes with a hook request. API injections never rebuild the game: missing slots are refused, and source rebuilds and call-site injections require internals access or a hook request. - Names: NeoForge 26.3 names for hooks that have a counterpart; no
on_prefix; the event is&mut; handles come from the event; writes takehost. - Layout:
horizon-mod.toml,assets/,data/,Cargo.tomlandsrc/at the top. The id and version come only fromhorizon-mod.toml; ids are[a-z0-9_]. Nonative/folder, nohorizon-native.json. - Manifest fields:
id,name,version,description,authors,api = "0.2",side,capabilities = ["registries"],[dependencies], top-levelinternalsand[[inject]],[native](game,horizon,mixins, optionalmodule) or[wasm],[[jar]]. One of[native]or[wasm], never both. Unknown fields are refused (manifest). - Freshness errors: a changed function hash or call context refuses the injection; use
inject updateor reselect it withinject add. Never substitute a different call. - Data and assets are read through a reload hook, once per reload, never on the tick.
- Missing hook? File a hook request on arismoko/horizon.
Where each doc lives
The book follows Diátaxis: a page is one of four kinds, and does only that job.
| Kind | Job | Lives in |
|---|---|---|
| Tutorial | Take a newcomer to a working result | Start |
| Explanation | Say how the pieces fit, with no steps | Concepts |
| How-to | One task, steps only | How-to, Injections |
| Reference | Facts, complete and dry | Reference, and the rustdoc of horizon-mod-api |
- A hook, event or
Hostmethod: its///lines in the declaration undercrates/horizon-mod-vocab/src/hooks/. The generator writes it as///on the generated item;cargo doc -p horizon-mod-apishows it. A hook without adocfails the build. - A hook whose game target has many parameters: a
native_*target takes at most 7 parameters (receiver included;MAX_TARGET_PARAMETERSincrates/horizon-mod-api-gen/src/dispatch.rs). A hook with more declares its parameters on the hook (fn update_shape(dimension: LevelDimension, ...);, as written inhooks/*.rs) and the target takes them as one generated<Name>Call(native_update_shape(&self, ticks, call: UpdateShapeCall<'_>)), handed todispatch_<name>(host, call). The generator refuses a flat target over the limit, and a target never carries#[allow(clippy::too_many_arguments)]. The hooks that were already over the limit are listed inFLAT_TARGETS_OVER_THE_LIMITin the same file; that list only shrinks (convert a hook, delete its line) and a new hook is never added to it. The carrier is host-internal plain data, never on wasm, and no modder sees it. - Hook notes: keep longer prose in a
#[notes = r#"..."#]attribute on its hook or helper declaration, heading included. General catalog prose lives inhooks/notes.rs.mdbook build docs/moddingrenders the Hook notes chapter from those attributes without writing a Markdown source page. To preview just that chapter, runcargo run --quiet -p xtask -- mod-api notes; do not commit its output. - A guide page:
docs/modding/src/. Code in a page is{{#include}}d fromdocs/modding/examples, which compiles, so a change to the API that breaks an example fails the build. - A mod API change updates its fragment
docand any page it affects in the same PR. - A new concept or how-to page: add the file directly under
concepts/orhow-to/; the book adds it to its part, after the pagesSUMMARY.mdlists, in order of its#title. Don't add it toSUMMARY.mdunless it must come first or sit in another part. Any other new page needs itsSUMMARY.mdline: the build fails on a page the book doesn't render, and on aSUMMARY.mdlink to a missing file. - Trait-mod and native-mod concepts: edit one source in
concepts/trait-mods/orhow-to/native-mods/. Sorted filenames set reading order; the leading<!-- chapter: Title -->supplies the navigation title. New files are discovered automatically, without a shared list edit. The old pages are assembled from these sources to preserve published links and heading anchors. Do not append prose to the aggregate pages or remove an existing heading anchor. Eachlegacy-anchors.txtfreezes the pre-split published anchors; never extend it per new concept. Optional<!-- separator: N -->metadata preserves original inter-section blank lines in aggregates without trailing blank lines in source files; new pages default to one blank line. Use relative links (inline or reference-style); root-relative links are refused. src/llms.txtis the index for agents; keep it in step withSUMMARY.md.
Build the book with mdbook build docs/modding and the reference with cargo doc -p horizon-mod-api --no-deps.
What a PR needs to show
Code review, CI and your own focused tests are the proof. You don't need to run the game.
A reviewer runs your change in game at your head only when a test can't cover one of these, and says which:
- rendering or UI, what the player sees;
- client-server sync: LAN guests, a dedicated server, packets;
- when or in what order something happens compared with vanilla, such as a hook's firing point.
If your PR changes or removes something mods already use, the reviewer also runs
horizon-machine-sample against it. A new item's /// doc is its
reference.
A catalog point and the wasm fixture
A new or changed catalog point is proven by the wasm fixture: real wasm mods (crates/horizon-mod-wasm/tests/fixture,
library-fixture and groups) built with cargo horizon-mod build --wasm and loaded by the real loader. The
wasm-mods CI job does all of it on a PR that touches crates/cargo-horizon-mod/, a crates/horizon-mod-* crate or
Cargo.lock, once the PR is ready for review (drafts skip it). That job is the answer; you don't need a local build.
To run it yourself, on Linux or Windows, follow the steps of .github/workflows/wasm-mods.yml from the repository
root; the tool installs its pinned toolchain through rustup. One wasm compile of horizon-mod-points takes about
10 GiB of memory, so set CARGO_BUILD_JOBS=1 on a 16 GiB machine. To check one change, build only the member that
uses the point and run its test. The build rewrites files in the fixture folders; don't commit them.
Scoped whole-stack rollback for internals mods
Proposal for #1407; this note does not authorize an implementation. TreeFall needs to retain a whole stack while trying a step and put it back if that step fails. The issue does not establish a need to keep snapshots across callbacks or ticks.
Decision for review
Prefer a transaction-scoped, same-place savepoint over a long-lived snapshot handle. Inside an existing server HostTransaction, an internals mod asks the host to capture the exact stack at a supported place and receives an opaque value usable only with that transaction and place. It can try the step and explicitly restore the captured stack before the transaction closes. Closing or aborting the transaction invalidates the savepoint. A decoded or copied guest token must not extend its authority: the host checks the live transaction and captured place on every use. At most one savepoint may be live in an entire root transaction and its nested transactions, including reentrant callbacks; a second capture returns AlreadyCaptured. A successful restore consumes it, so another capture can then succeed; closing its owning transaction also releases it. This bounds retention without a persistent per-mod registry, release API, principal binding, or cross-tick quota policy. The savepoint is not a serialized ItemStack and grants no access to engine-owned data.
This serves the stated same-step use if the capture, attempted mutation and decision all occur in one transaction. It does not serve a mod that must keep the stack over a later tick or callback. The real lifetime of TreeFall's transaction must be confirmed before building; confirm its actual call boundary before implementation; if it needs the longer lifetime, bring that use and its resource/ownership requirements back for design rather than silently widening this contract.
Capture and restore preserve the complete engine stack, including opaque wire-backed fields, unknown component and runtime data, count, and the empty state. Never reconstruct the engine stack through its public API projection. The handle promises neither Copy semantics nor free or hidden full-stack clones; copying guest token bytes duplicates neither the retained value nor its authority. The host identifies the original place by its level, holder incarnation and slot/hand identity, not a bare entity number. Ordinary changes to the stack in that same place are exactly what restore is meant to undo; a replaced holder, changed selected slot, stale identity or different destination must instead refuse. The host validates the captured value against the destination's current constraints before writing; if it no longer fits, it returns InvalidSnapshot without changing the journal rather than truncating or partially restoring it. Reusing a closed transaction or restoring to a different place is a refusal, not a best-effort write.
Native panic/unwind, wasm trap and world close must run the owning transaction's abort/teardown: discard pending restore writes, release the retained engine value and permanently invalidate the token. Cleanup must also work when teardown occurs with a live savepoint, without an explicit restore.
Boundary and named refusals
For initial review, support only a player's selected main-hand hotbar slot, identified as that player's concrete ItemPlace::PlayerSlot at capture, using the transaction's already-journaled player inventory. Confirm TreeFall actually uses that place before committing to the boundary. Refuse other places, including equipment and offhand, until their transaction coverage and the use case are established. Explicitly refuse mod-owned ItemPlace::Mod, overriding item-capability providers, and nested places. A provider's readable API stack need not contain the complete backing value, and its existing insert/extract interface does not promise an atomic, generation-checked whole-stack replacement. Treating it as game-owned could overwrite a different provider lifetime or discard data. A named refusal is safer than expanding PlaceProvider or ResourceHandler for every mod before the use requires it.
Use distinguishable, wire-safe results rather than a false success: UnsupportedPlace (including a provider-owned backing), ReadOnly, StalePlace, ClosedTransaction, InvalidSnapshot, and AlreadyCaptured for the single-savepoint bound. Final names are part of API review. Failed capture or restore changes neither stack nor journal; failed restore leaves the savepoint live until its transaction closes. A mod may handle a refusal by aborting the step; there is no fallback to the underlying game slot.
Transaction and pending-item overlay
A savepoint is a value within the existing item-place transaction, not a second rollback journal. Capture reads the transaction's current view, including prior pending writes; restore stages a write in that same journal. A nested transaction's accepted changes remain subject to the outer transaction, and an outer abort discards them. Restore cannot commit the transaction, undo unrelated effects, or make immediate host writes transactional by itself.
#2846 is separately designing a block-entity-tick journal and a reads-own-writes overlay for dropped-item writes and removal; it is not implemented by this proposal. Where that overlay applies, capture must read its effective pending stack and restore must stage into the same overlay, with its level/incarnation and stale-item checks. If no common transaction/overlay view exists for a place, refuse it until one does. Do not add another dropped-item rollback mechanism or claim that effects outside the #2846 boundary become atomic.
Evidence required before landing
- Establish TreeFall's actual snapshot/restore call and transaction lifetime; if it crosses the proposed boundary, revise the design first.
- Exercise native calls against real Game storage and real wasm-to-Game calls against the same supported place: exact full-stack and empty-stack restoration, opaque wire-field preservation, prior pending writes, nested commit/outer abort, and no partial change on refusal. Both targets must have identical contracts, including refusals and consumption on successful restore.
- Cover native panic/unwind, wasm trap and world close while a savepoint is live: pending restore writes are discarded, the retained value is released and copied tokens remain permanently invalid.
- Cover moved/replaced holders, slot changes, same numeric ID with a new incarnation, another level, closed transactions, repeated capture, read-only and provider-owned places; prove provider refusal never writes through to game storage.
- Compose with the accepted #2846 overlay design and test reads-own-writes, stale item and rejected block-entity writeback once that contract exists. No claim of #2846 implementation is made here.