Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

KeyMeaning
idthe recipe id; * matches any run of characters (minecraft:*_planks)
typethe recipe's type, with the same * (mymod:*)
inputan item id or #tag among the recipe's inputs
outputan item id among its outputs (anything under result, results, output or outputs)
removetrue: the recipe is not loaded
replace_input{"from": ..., "to": ...}: every input that is from becomes to
replace_outputthe 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 (the mods in its native-mods.json differ 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 base of its native-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.

  1. Rust. The launcher's own toolchain (rust_toolchain): Rust 1.99.1's official rustc, cargo and rust-std (and rust-mingw on 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 the windows-gnu host, which links without Visual Studio. rust-mingw only 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 like rust-mingw, one zip pinned by SHA-256, its mingw64/ without the documentation) on the build's PATH: ring and hidapi compile C, and windows-sys' raw-dylib imports need dlltool and the assembler it runs. The linker is the system's on macOS (the Command Line Tools: the launcher asks for xcode-select --install) and Linux (cc). On Linux the game also builds against the system's ALSA (alsa-sys) and libudev (libudev-sys, hidapi), found with pkg-config, whose development files desktop distributions don't install. Before downloading anything the launcher checks for cc, the C library's headers (cc compiles an include of stdint.h), pkg-config, alsa and libudev and 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 with os 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.

  2. horizon-mixins, at the exact commit the mods name ([native] mixins in horizon-mod.toml), built once with that cargo (cargo install --locked --git … --rev …) into native/tools/<commit>/.

  3. 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.

  4. 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.stamped marks it). Registry dependencies are checked against Horizon's Cargo.lock before Cargo resolves the staged workspace (The mod's crate).

  5. Compile: cargo build --locked --profile <fast|release> [--config ...] [--features horizon-client/mods] -p horizon-native-game, into native/target/, with one of three build modes:

    ModeCargo profileUse
    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, and lto = 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
    developerfast with horizon-client at 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.

    dev lowers only horizon-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 or native-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.json records the effective profile.

    Each profile is in the build's key, so their builds never mix and may all stay. balanced and developer share native/target/fast/: every crate but horizon-client is built with the same settings, so switching between them recompiles only horizon-client, and Cargo keeps both builds of it, so switching back finds the other still there. optimized has 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 is native/build.log; a failure shows its last lines (a horizon-mixins diagnostic in one line).

  6. 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 a native-mods.json saying which release or Nightly it started from (base) and which commit, mods, horizon-mixins and Rust made it. Canonical mod projects are copied into horizon-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_TOKEN raises it) turns the link into a release's .hmod file or a commit SHA, never a branch that moves. A release with a .hmod asset installs that file (its SHA-256 is compared with the one GitHub lists). With no release or no such asset, the launcher fetches codeload.github.com/<owner>/<repo>/tar.gz/<sha> and looks in it for mods by the rules of native-build <folder>: a flat project with horizon-mod.toml, [native], Cargo.toml and src/. 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 .hmod is read as horizon_mod_package::index::native::Bundle::read reads 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, as native-build does. 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.json beside the .hmod: owner/repo, release tag (for a release), commit, asset name and the SHA-256 of the installed file. mods list shows "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 .hmod asset 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 .hmod contains the flat Rust project and may contain assets/, data/ and an optional icon.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 from horizon-mod.toml. revoked.toml takes 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-output conflicts with --instance and needs an explicit mod FOLDER). 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:

  1. 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).
  2. horizon-server --dir SERVER config set engine.path=$ENGINE/horizon-engine; horizon-server --dir SERVER run.
  3. Join with the same mod's client half where the hook needs one; test lifecycle, tick, command and net hooks.
  4. 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 routeLevel eventVanilla call being represented
Placement confirmation, predicted doors/buttons/leversAtPositionBlockItem.place, block useWithoutItem → Level.playSound
Waxing, tool transforms, bucket useAtPositionHoneycombItem.useOn, tool useOn, BucketItem → Level.playSound
Game-event arrow hit (Players), pufferfish sting (Neutral), elder-guardian curse (Hostile)AtPositionClientPacketListener.handleGameEvent → Level.playSound
Own steps, swimming, splash, amethyst chime, honey slide, fallAtEntityEntity/LivingEntity → virtual LocalPlayer.playSound
Own hurt/death, bundle menu clicks, consume effectsAtEntityLivingEntity, BundleItem, Consumable → LocalPlayer.playSound
Player-bound sounds, including the local goat hornAtEntityLevel.playSound(except, entity, ...)
Block-breaking/level-event effects, explosions, pickup packetsNoneClient-level playLocalSound or direct sound manager playback
Dragon/fang/sniffer local effects, ticking ambience, UINoneEntity-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 #tag gives every entry in it the value, replace drops what earlier files set and remove takes 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 one decode takes.
  • 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.