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.