Files
horizon/docs/native-mods.md
T
rdbt 3faaef6693 Merge pull request #5161 from arismoko/hook-fragments
Batch native hook API numbers from fragments
2026-10-31 16:08:30 +00:00

57 KiB

Native mods

Native mods are mixin bundles that change the game's own Rust source before it is compiled, with horizon-mixins (MIT, by levzzz5154, made for Horizon). A bundle is a horizon-mixin.toml and a few .rs helpers that hook into named functions of the game (HEAD, BEFORE_CALL, RETURN, MODIFY_ARG, OVERWRITE...; the tool's README lists them). A port such as Veinminer is three HEAD hooks and one module. Start a mod from horizon-native-mod-template.

Native mods are native code with the player's rights, not sandboxed. They are always on in the launcher: there is no switch. 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); and they play in a game of their own, built for the instance they are installed in.

Pinned to a Nightly

A native mod builds on one exact Horizon commit: [compatibility] horizon_revision in its horizon-mixin.toml, and horizon in the horizon-native.json of its registry file (.hnmod, horizon_mods::index::native). Mods pin a Nightly's commit (or a release's). Every Nightly ships its source and keeps its commit for good as the tag nightly-source/<date>-<short> (release-flow.md), so a pinned mod still builds after its Nightly is replaced. When the mods of one build pin different commits, the launcher says which mod pins which and the commit the build would use, the newest of them, and builds only after the player chooses Build anyway (native-build --allow-version-mismatch): such mods often still apply and compile. A failed build then says the mods were made for other versions. Their horizon-mixins pins may differ too: the build uses the newest of them and the launcher's own, never one older than a mod was made with. A bundle with no pin builds on the commit of the installed version the build starts from.

Mods on the hook points

A mod that hooks only Horizon's native hook points (crates/horizon-client/src/game/native_hooks.rs: empty functions the game calls at fixed places, plus helpers, kept stable across releases) can name the version it needs instead of pinning: "hooks": 1 in its .hnmod's horizon-native.json, the NATIVE_HOOKS_API of that file when it was made. Hook points are only ever added: each addition raises NATIVE_HOOKS_API, and a mod builds on any Horizon whose version is the one it needs or a later one. Its horizon is then only the commit it was tested on. The launcher builds it on the commit the other mods pin, else on the installed version's commit (the game the player plays), after checking that commit's NATIVE_HOOKS_API (read from GitHub) is at least the mod's; when it isn't and nothing pins, it builds on the commit the mod was tested on. In its own copy of the mod it sets horizon_revision to the commit it builds on. Horizon's own native mods (Zoom, FPS counter, Keystrokes, Minimap; horizon-rust/horizon-mods, native/<name>/) are made this way, so any of them build together with no re-release for each Nightly.

Hook and helper authors use API fragments to request a number.

API 2 adds the item panel beside container screens and its recipe pages (native_panel_items, native_panel_recipes, and the hook native_panel_event for the player's clicks, searches and keys over items), and the game's items and recipes (native_recipes, native_known_recipes, native_place_recipe, native_give): what a recipe viewer needs.

API 11 adds GameLevel::native_send_particles on authoritative servers. It sends supported vanilla particles to nearby players. Block particles require a valid block state. Item, trail and vibration particles are not supported by the current dispatch. Count zero sends one directional particle. The return value says whether the call was accepted, not whether any player received it. Bundles that use it need "hooks": 11.

API 3 adds authoritative world lifecycle events through native_world_lifecycle: open ready, open failed, and final close. Bundles that use these events need "hooks": 3.

API 4 adds authoritative server tick events through native_server_tick: start (before any level ticks, like NeoForge's ServerTickEvent.Pre) and end of each non-paused server tick, with world_running. A frozen world reports false except during a requested step. Bundles that use these events need "hooks": 4.

API 8 adds server commands and players: native_register_commands (the commands a mod adds to every player's command tree) and native_perform_command (a player's command on the server), with helpers to answer (Server commands and players). Bundles that use them need "hooks": 8.

API 9 adds the acting player's plain items for server mods (count, take, give; Server commands and players). Bundles that use them need "hooks": 9.

API 10 adds server menus: a vanilla chest menu a server mod opens and fills, whose clicks and closing reach the mod (native_menu_clicked, native_menu_closed), and native_exchange_items, two players' stacks swapped in one checked step (Server menus). Bundles that use them need "hooks": 10.

API 12 adds item tooltips through native_tooltip: the hovered stack, with its components, and its tooltip's lines, which a mod can add to, change or take out of, wherever the game shows an item's tooltip. Bundles that use it need "hooks": 12.

API 13 adds using an item, on the server for every player: native_use_item_on hears a right click with an item on a block once the block let it through (ItemStack.useOn), and native_use_item hears the item's own use, in the air or after native_use_item_on let it through (Item.use). Each hears a click once. A mod that claims one swings the hand instead of the item's use. Bundles that use them need "hooks": 13.

API 14 adds breaking and placing a block, on the server for every player: native_destroy_block hears a player break a block once the game allowed it (ServerPlayerGameMode.destroyBlock) and may cancel it, which keeps the block and its drops; native_place_block hears a block placed from an item (BlockItem.place). To stop a placement, a mod claims native_use_item_on, which runs before it. Bundles that use them need "hooks": 14.

API 15 adds block entities for a mod's blocks (native_block_entity, native_block_entity_ticks, native_block_entity_tick): made when the block is placed, ticked on the server and saved with the chunk. Bundles that use them need "hooks": 15.

API 16 adds item slots for those block entities (native_block_entity_container): hoppers, comparators and breaking the block use them as they use vanilla's containers, and native_block_entity_tick also gets the slots. Bundles that use them need "hooks": 16.

API 17 adds menus for mod containers: the container names one of vanilla's slot-grid or furnace menus, which opens when the block is used, and native_block_entity_tick takes the whole block entity, with the data slots the menu shows. Bundles that use them need "hooks": 17.

API 18 adds what a machine's tick reaches: native_with_mod_block_entity (change a neighbouring mod block entity, which marks its chunk and tells comparators when its items changed) and native_set_block_property (a property of the block's own state, through the level's normal update path). Bundles that use them need "hooks": 18.

API 19 adds screens a mod draws for its blocks' menus: native_screen lays one out (its height, where each container slot is, where the inventory starts; a slot it leaves out is not in the menu, so shift-clicks skip it) and native_screen_draw draws it with the mod's own GUI textures (assets/<namespace>/textures/gui, drawn with native_sprite). Bundles that use them need "hooks": 19.

API 20 adds buttons, tooltips and item icons on mod screens: native_screen_click and native_screen_tooltip (the client), native_menu_button (a button press on the server, on the mod block entity) and the helpers native_item, native_screen_title, native_data, native_block, native_block_property, native_insert_item and native_cursor. Bundles that use them need "hooks": 20.

API 21 adds what a server mod needs to guard land and move players. native_right_click_block hears a player's right click on a block before the block's own use (a chest, a door, a lever) and the item's, and may cancel it: nothing happens and the player sees the block as it is. native_acting_player says who acts inside a player's hook (UUID, name, level, position, game mode, health); native_run_command runs a command as the server's console (level 4, no output), so execute in <dimension> run tp <player> <x> <y> <z> moves a player across levels; native_action_bar tells a player why, even inside their own hook; and the players native_server_players lists carry their health. Bundles that use them need "hooks": 21.

API 22 adds what a server mod needs to keep the world itself out of guarded land. native_block_spread hears a fluid flow or fire spread into a block, native_piston_move a piston's push or pull, native_explosion_detonate the blocks an explosion is about to break (a mod takes out those it spares), native_fill_bucket a bucket filled or emptied, and native_attack_entity a player's blow on an entity (an item frame, an armor stand, an animal, a player). Each may cancel, and nothing happens. Bundles that use them need "hooks": 22.

API 23 adds native_level_tick_pre and native_level_tick_post, server-side observations around each level tick pass. Each receives the dimension ID; neither can cancel the tick. Paused and held passes fire neither hook, and an error skips the post hook. Bundles that use them need "hooks": 23.

API 24 adds native_set_block, a server-side Game helper that replaces a state in the current dimension through Level.setBlock. It accepts vanilla's neighbour and shape flags and returns whether a block changed; a block with a block entity gets one. Horizon sends every write to players whatever the flags: UPDATE_CLIENTS, UPDATE_INVISIBLE and UPDATE_IMMEDIATE change nothing yet. Hooks holding only GameLevel cannot call it yet. Bundles that use it need "hooks": 24.

API 25 adds GameLevel::native_play_sound for registered vanilla sound events on an authoritative server. It queues the sound for nearby players in the level's dimension, including during a guest's turn. Pass a SOUND_SOURCES index, position, volume and pitch. The return value says whether the call was queued, not whether a player heard it. The server discards unknown sound events when it dispatches the queue. Bundles that use it need "hooks": 25.

API 26 adds Game::native_config_dir(): the game's shared config/ folder beside options.txt (beside server.properties on a dedicated server), where a mod names its own files, made when missing. It takes no self, so it works from any hook (mods.md). Bundles that use it need "hooks": 26.

API 27 adds the player-health HUD rows through native_health_hud: every frame the HUD draws them, the hook hears the layout's inputs (HealthInputs) and the screen's GUI size, and a mod that answers with draws replaces all the rows — armour, hearts, food, air and the heights above the hotbar — for that frame. Nothing answered draws Horizon's own. Bundles that use it need "hooks": 27.

API 28 adds native_block_in_tag and native_item_in_tag for server tick hooks. They read the world's merged block and item tags, including data packs and content mods. An unknown tag returns false. An empty item stack tests the minecraft:air item, as vanilla does. Calls with an inactive or stale tick context return an error. Bundles that use them need "hooks": 28.

API 29 adds right-clicking an entity, on the server for every player: native_interact_entity hears a right click on an entity (its id and type, and the stack in the hand) where Player.interactOn starts, once per hand. A mod that claims it swings the hand instead of the entity's and the item's own interaction. Bundles that use it need "hooks": 29.

API 30 adds left-clicking a block, on the server for every player: native_left_click_block hears the start of a dig (the block, the face hit) once the game allowed it, before the block is attacked (a note block plays) or broken in creative. A mod that cancels it keeps the block: on the server it isn't attacked, dug or broken, and the player's client gets it back. The clicking player's own client still predicts the crack until then. Bundles that use it need "hooks": 30.

API 31 adds a player's server tick: native_player_tick runs once a tick for every living player in the world, at the end of their ServerPlayer.tick, with the helpers reading that player. Bundles that use it need "hooks": 31.

API 32 adds shape updates a mod can answer through native_update_shape: on an authoritative server, each time a block's state is worked out from a neighbour (BlockBehaviour.updateShape): neighbour shape updates after a setBlock, a block fitted to all its neighbours as a piston lands it or a chunk is post-processed, and the blocks at a feature's edge as it is placed in the live world, such as a tree grown from a sapling (StructureTemplate.updateShapeAtEdge). It gets the dimension, position, state, the neighbour's direction, position and state, before the block computes it. state may not be in the world yet (a piston landing it), so decide from state, not from the block at pos. Answer only for your own blocks and leave new empty otherwise: a state put there is the block's new state, applied as vanilla applies the result (air breaks the block, with its drops, on a neighbour update; post-processing and a feature's edge write air without drops), and the block's own updateShape doesn't run, nor the ticks it would schedule. The level is read-only, as vanilla's LevelReader is there; the hook schedules ticks with its ticks through the level's native_schedule_block_tick(ticks, pos, block, delay) and native_schedule_fluid_tick(ticks, pos, fluid, delay) (vanilla's ScheduledTickAccess.scheduleTick; false for an unknown name or a position that isn't loaded). It runs often, at least six times per setBlock that updates shapes (more for redstone wire's diagonals and knock-on updates): keep it short. A LAN guest shows vanilla's shape for a moment, until the server's update arrives. The deprecated in-frame path and chunk generation fire none. Bundles that use it need "hooks": 32.

API 33 adds random ticks a mod can handle through native_random_tick: on an authoritative server, each block ServerLevel.tickChunk random-ticks (BlockState.randomTick), with its dimension, position and state, before the block handles it. Only blocks that tick randomly fire it (a mod block as its vanilla template block does), once per draw that lands on one: it runs often, so keep it short. Bone meal's random ticks (StemBlock.performBonemeal calls randomTick itself) don't fire it. The hook may change the block with the level's native_set_block_property(ticks, pos, key, value, flags) and set handled to skip the block's own random tick, so a mod crop grows its own way. Without handled, the block then takes its own random tick as it is now (if it still ticks randomly), and lava's fluid random tick runs either way. It needs the level mutably, which only native_random_tick and API 34's native_tick_block give. It is API 18's Game::native_set_block_property with Level.setBlock's flags (native_hooks::flags): a vanilla crop grows with flags::UPDATE_CLIENTS (neighbours' shapes update, so an observer fires, but no neighbour gets neighborChanged and comparators aren't told), a furnace sets its lit with flags::UPDATE_ALL (both). Horizon sends every write to players whatever the flags: UPDATE_CLIENTS (2), UPDATE_INVISIBLE (4) and UPDATE_IMMEDIATE (8) change nothing yet. It writes at any loaded position, returns false off an authoritative server (the Game one too: a window that isn't a server), for a property or value the block doesn't have or a position that isn't loaded, and true without writing when the property already has the value. Elsewhere, Game::native_set_block_property_with(pos, key, value, flags) does the same, as a brewing stand's tick sets its bottles with flags::UPDATE_CLIENTS. The deprecated in-frame path fires none. Bundles that use them need "hooks": 33.

API 34 adds scheduled block ticks a mod can handle through native_tick_block: on an authoritative server, each due scheduled tick of a block that is still there (ServerLevel.tickBlock → BlockState.tick), with its dimension, position and state, before the block handles it. The hook may change the block with the level's native_set_block_property and the flags the block's own tick would write with (as in API 33: an observer's tick uses flags::UPDATE_CLIENTS) and set handled to skip the block's own tick. Without handled, the block then takes its own tick as it is now, if it is still the tick's block. The deprecated in-frame path fires none. Bundles that use it need "hooks": 34.

API 35 adds the commands a mod owns on the client: native_commands is asked for their names, so the chat box colours, completes and lists them like the game's own, and native_command runs them. A name the game already has keeps that command. Bundles that use it need "hooks": 35.

API 36 adds recipes of a mod's own types, for a machine's crusher or altar. A recipe file whose type is outside minecraft (mymod:crusher) in data/<namespace>/recipe/, in a content mod (.hmod) or a world's data pack (not in a native mod's own bundle), stays loaded as it is: native_recipes_of_type gives each one's id and JSON, for the mod to read as it likes, and native_recipe_ingredient and native_recipe_result read the usual fields, tags included (a bare id is read as minecraft:; items the game doesn't know are left out, and the helper gives nothing if none is left). The recipes are the server's table: on the server thread, a dedicated server or a LAN host; a client, the single-player window included, sees only the content mods' recipes, not the world's data packs'. All three give nothing outside a world. The game doesn't craft them or show them in the recipe book; /reload reads a data pack's again. Bundles that use them need "hooks": 36.

API 37 adds mobs for server mods: spawn one, read the mobs of a level in place, and move, hurt or remove one by its id. Bundles that use them need "hooks": 37.

API 38 adds redstone from a mod's block, on authoritative servers. native_block_signal may change the signal a block gives on a face, weak or direct, and native_is_signal_source lets redstone wire connect to it. Both are asked very often, so check the block first and answer from the mod's own state. When the output changes, and once the block is placed (a direct signal past a conductor isn't read before), native_update_signal_neighbors(dimension, pos) makes the blocks around it read it again; it acts on the level in place and is refused for another dimension. Bundles that use them need "hooks": 38.

API 39 adds item behaviour, on the server for every player: native_hurt_enemy hears a player's hit land on a mob with the held stack, native_inventory_tick runs each tick for every stack a player carries (armour and off hand included), and native_carried_stack reads and changes that stack, and native_on_equip hears an equipment slot take a different item. The helpers read the acting player. Bundles that use them need "hooks": 39.

API 40 adds mod messages between a mod's server and client halves, and hearing players and mobs on the server for every player: native_player_died, native_mob_spawned, native_mob_killed (a player's melee blow, a sweep or a spear thrust too), native_item_dropped (a throw; not the items scattered when a player dies) and native_item_picked_up (canceled leaves the stack on the ground). They name a player by UUID. Bundles that use them need "hooks": 40.

API 41 adds bundles' data files (native_data!, native_built_data; "Data files" below). Bundles that use them need "hooks": 41.

API 42 adds the item entities near a block, for machines that pick up items as a hopper does: native_item_entities(dimension, min, max) lists those whose box meets min..max (vanilla's Level.getEntitiesOfClass(ItemEntity.class, box)), each with its entity ID, position, stack and pickup delay, and native_set_item_entity(dimension, id, stack) changes one's stack, or removes it when the stack is empty, as a hopper does with what it takes. Both act on the level in place, as native_set_block does. Bundles that use them need "hooks": 42.

API 43 adds native_level_random(dimension), the server level's own random (vanilla's Level.random, NeoForge mods' level.getRandom()): a mod draws from the stream vanilla's explosions and block drops use, so its draws shift theirs as a Java mod's would. It is None on a client of a server and for a level that isn't open: the Nether and the End open when someone first goes there and close after 300 ticks with nobody in them. Bundles that use it need "hooks": 43.

A bundle

A bundle is a folder with a horizon-mixin.toml. Besides its helpers and modules (horizon-mixins' README) it can have its own crate, data files and a post-build step (horizon-mixins 0.6.0). The test bundle launcher/tests/fixtures/native-crate-demo uses all of them:

native-crate-demo/
  horizon-mixin.toml
  frame.rs                # helper, compiled into the game
  data/greeting.txt       # the bundle's data: native_data!("crate_demo", "greeting.txt")
  engine/                 # the mod's own crate
    Cargo.toml            # [dependencies] crc32fast = "1"
    data/salt.txt         # the crate's data: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/data/salt.txt"))
    src/lib.rs
    src/bin/bake.rs       # the post-build step
[mod]
id = "crate_demo"
version = "0.1.0"

[[injection]]
id = "frame"
hook = "frame"            # a name from native-hooks.json, instead of `target`
hook_version = 1
kind = "HEAD"
helper = "frame.rs"
bindings = [{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" }]

[[crate]]
id = "engine"
path = "engine"           # holds Cargo.toml
# dependent = "horizon-client"   (the default: the workspace package that depends on it)

[[post_build]]
crate = "engine"
bin = "bake"
args = []

Named hook points

native-hooks.json at the root of the source names each hook point and the version of its contract (signature, when it is called, what it does): {"name": "hud", "version": 1, "target": "horizon_client::game::native_hooks::Game<'_>::native_hud"}. An injection names hook and hook_version instead of target, and horizon-mixins resolves it from the catalog of the source it applies to, so a hook point can move in the source without breaking the mods that use it. A name the catalog lacks is E_HOOK_UNKNOWN (the error lists the names it has), a version it lacks E_HOOK_VERSION. A contract is never changed: an incompatible change adds a version. Helpers (native_text, native_player…) are not in the catalog; they are kept stable as the hook points are. A test (catalog_matches_hook_points) keeps the catalog and native_hooks.rs in step.

The mod's crate

A [[crate]] is a Cargo package of the bundle's, staged as native-crates/<mod>/<crate>/ and added to the workspace's members and to the [dependencies] of its dependent, so helpers call it as crate_demo_engine::checksum(…). Cargo compiles it once and keeps it: editing a helper, or switching another mod on or off, recompiles horizon-client but not the mod's crate, so the heavy code of a big mod belongs there and only the glue in helpers. A crate can't depend on horizon-client (Cargo forbids the cycle) or on any path or git crate, and has no Cargo.lock, [workspace] or build of its own. Horizon's own crates (horizon-core…) are path crates too, so they are refused the same way, on purpose for now: a mod names game types through its helpers, which are compiled into horizon-client; a stable API crate for mods' own crates is #3897.

Dependencies come from Horizon's Cargo.lock. A crate names registry crates by version requirement (crc32fast = "1", { version, features, default-features, optional }); each must already be in Horizon's lock file in a version that matches, and builds in exactly that version. The lock file is the allow list: horizon-mixins refuses a crate the lock lacks (E_CRATE_DEPENDENCY) and writes the mod's crates into the staged lock without changing any other byte, so the game still builds with --locked. The launcher checks it again (every downloaded package of the staged lock as in Horizon's: name, version, source, checksum), and a feature that turns on a dependency the lock lacks fails at cargo fetch --locked with a message saying so. The build stops with:

horizon-mixins failed (exit status: 1). The end of its output, the whole of it in …/native/build.log:
E_CRATE_DEPENDENCY: crate_lock needs rand ^0.9, which isn't in Horizon's Cargo.lock (…/horizon-mixin.toml)

Data files

A bundle's data/ folder (any files) is staged as native-data/<mod>/ at the root of the workspace. A helper or module compiles a file in with horizon_client::native_data!("<mod id>", "path/in/data") (&'static [u8; N], crate::native_data! inside the lib), since hook points API 41. A mod's crate keeps its own data in its folder and reads it with include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/data/…")). The registry's .hnmod may carry both (mixins/<id>/data/…, mixins/<id>/<crate>/data/…).

Both are compiled in: neither folder is beside the game when it runs. A file the game reads at run time is one a post-build step writes into HORIZON_NATIVE_DATA_DIR (below), copying it from HORIZON_NATIVE_BUNDLE_DATA_DIR or making it; the game finds it with native_built_data("<mod>") (None only when the game can't tell its own folder).

What a .hnmod holds. Only files under mixins/<id>/: .rs and .toml files, the data folders above, and the text files a crate folder carries (README*, LICENSE*, COPYING*, NOTICE*, .gitignore, in the bundle's folder or a crate's), so a crate that compiles its README in builds the same from the registry as from a folder. At most 512 files and 64 MiB unpacked; anything else refuses the bundle. A local folder build has no such limits.

Post-build steps

A [[post_build]] names a bin of one of the mod's crates. After the game compiles, the launcher builds it with the same profile into the same native/target/, then runs it (at low priority, in mod order) in the new build's version folder with:

Variable What
HORIZON_NATIVE_MOD the mod's id
HORIZON_NATIVE_VERSION_DIR the new build's version folder (also the working directory)
HORIZON_NATIVE_DATA_DIR <version folder>/native-data/<mod>/, created: what the game reads at run time with native_built_data("<mod>") (or horizon_client::native_data::built)
HORIZON_NATIVE_CACHE_DIR <launcher data>/native/cache/<mod>/, created, kept from one build to the next
HORIZON_NATIVE_BUNDLE_DATA_DIR the bundle's own data/ as staged for the build (native/stage/native-data/<mod>/); read it, don't write it

Its output goes to native/build.log. A native-post-build.json that can't be read, or names a mod id that isn't one, stops the build instead of skipping the steps. When a step fails, so does the build ("crate_demo's post-build step failed …") and the version folder is removed. It runs on the player's computer like the rest of the mod's code: only the launcher runs it, never horizon-mixins.

What the launcher does

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; the first build downloads Rust and can take 20 to 30 minutes; later builds are faster); 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);
  • Rebuild on Horizon {version} when a newer release or Nightly is installed (native_mods::update_for). Build stays on the Horizon the instance plays; only that button moves it, and its warning says the build starts from scratch and takes longer.

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 .hnmod, 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 on Apple Silicon, Linux x86-64 and Windows x64; an Intel Mac has none yet, so native builds there stop with "the launcher has none for macos x86_64". 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. horizon-launcher rust status|install|remove.
  2. horizon-mixins, at the commit the mods name (their .hnmod's mixins), else native_mods::MIXINS_REV, 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. Mixins. The bundles are copied first, so the build and its key use that copy whatever happens to the folder meanwhile. horizon-mixins apply stages the modded source; the launcher copies only the files that changed into native/stage/, so Cargo, which goes by file times, recompiles only the crates the mods touch. With mods' crates, their dependencies are checked against Horizon's Cargo.lock ("The mod's crate").
  5. Compile: cargo build --locked --profile fast [--features horizon-client/mods] -p horizon-client, into native/target/. fast is the workspace's own profile: the release's optimizations without its link-time ones (lto = false, codegen-units = 16, incremental), so a rebuild recompiles and links only what changed. A revision whose Cargo.toml has no fast builds with release, the release's own command (game-build.yml); native-mods.json says which (profile). The Mods page's "Native build" row → "Release build: slower to build, slightly faster game", horizon-launcher settings native-profile release or native-build --profile release compile the release's own profile instead (thin LTO). The profile is in the build's key and each has its own folder (native/target/fast/, native/target/release/), so the two never mix and may both stay. The mods feature is passed while the revision still has it. 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. Post-build: each mod's post-build program is built; it runs in the new version folder once that is copied (next step), before the version counts as installed ("Post-build steps").
  7. 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. 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. An instance built before this (the Native mods instance the default home's mods used to go to) keeps its worlds and its build.

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.

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 .hnmod file or a commit SHA, never a branch that moves. A release with a .hnmod 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 folder with horizon-mixin.toml, or an unpacked .hnmod (horizon-native.json). Several mods: they are listed, and the player picks one or installs all. A source folder must pin the Horizon commit ([compatibility] horizon_revision); the launcher packs its Rust, TOML and data/ files into a .hnmod. A release that ships only a content mod (.hmod) says "not supported yet": installing .hmod files from a link is not built.
  • Checked like the registry's files. The .hnmod is read as horizon_mods::index::native::Bundle::read reads a registry file (only Rust, TOML and data under mixins/<id>/, 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 .hnmod: 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 fast, Cargo recompiles only the changed parts of horizon-client, not the whole crate with LTO. 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. release has no incremental compilation (the release profile's own).

Build status. Before it starts, the build says which case it is, with the profile: "Up to date (no build needed)", "Incremental rebuild: mods X, Y" (or "Horizon commit a to b", "horizon-mixins"; compared with the last build of that profile, kept in native/built.json) or "First build: the whole game (several minutes)". While compiling it shows the crate, crates done of the total (Cargo's own progress), and the time taken; afterwards "Built in 42 s (fast, incremental)". The window shows it in the Native mods row, native-build prints each step with its time on standard error; native/build.log keeps Cargo's output as before (without the progress bar).

Measured 2026-10-30 on Linux (x86_64 desktop), Zoom 2.0.0 and FPS Counter 2.0.0 on Nightly 6f8c7a0, the fast profile, the launcher at nice 19 with CARGO_BUILD_JOBS=10 (an empty launcher home: Rust, the crates and the source downloaded first):

What Time
First build: Rust, horizon-mixins, the whole game (384 crates) 4 min 29 s in all; 3 min 6 s of compiling the game; native/target/ about 2 GB
One mod switched off, rebuild 56 s in all, 51 s of compiling (only horizon-client)
That mod switched on again under 1 s: the kept build plays again
Nothing changed, rebuild 1 s, nothing compiled

The test bundle native-crate-demo (its own crate on crc32fast, data files, the frame hook by name, a post-build step), measured the same way on 2026-10-30 (Rust already downloaded):

What Time Compiled
First build (horizon-mixins, the crates, the whole game) 3 min 27 s; 2 min 24 s compiling the game 343 crates
A helper's code changed 48 s (43 s compiling) horizon-client only: the mod's crate is kept
The mod's crate changed 11 s (6 s compiling) crate-demo-engine, then horizon-client
Nothing changed under 1 s nothing

The same set with the release profile took 7 min 6 s for the first build and 2 min 34 s per rebuild (2 min 28 s of compiling horizon-client, mostly its link-time optimization). In play, a fast game drew the same frames per second as the Nightly's own release build (vsync-bound, about 112 FPS at 120 Hz), with about 0.1 ms more CPU per frame (0.57 to 0.68 ms against 0.51 to 0.54 ms).

Measured before builds used fast, with the release profile, on 2026-10-28 on the Mac mini (M4 Pro, 14 cores) with Veinminer 0.3.0 on Nightly 83f15fa, CARGO_BUILD_JOBS=8 while other agents' builds ran (upper bounds):

What Size, time
Rust 1.99.1 (<launcher data>/rust/) 453 MB; 58 s to download and unpack
horizon-mixins (native/tools/) 7 MB; built once in 19 s
Crates in the launcher's Cargo home (<launcher data>/cargo/) 289 MB
Source of one commit, and the stage 64 MB each
First build: the whole game 9 min 10 s of compiling, 9 min 41 s in all; native/target/ 1.5 GB
Rebuild after a helper's code changed 2 min 46 s of compiling (only horizon-client), 2 min 53 s in all
A change horizon-mixins ignores (a comment) nothing compiled; 31 s, mostly the version folder's copy

Measured 2026-10-29 on Windows 11 (Ryzen 7 9800X3D, 16 threads), the same mod and Nightly, an empty native/target/ (horizon-mixins, the source and the crates were already downloaded):

What Size, time
Rust 1.99.1 and MinGW-w64 (<launcher data>/rust/) 1.7 GB, MinGW-w64 875 MB of it
Crates in the launcher's Cargo home 657 MB
First build: the whole game 6 min 57 s of compiling, 8 min 3 s in all; native/target/ 2.0 GB

Measured 2026-10-28 on Linux (an x86_64 server with no Rust of its own), Zoom on Horizon 1149c92, 3 jobs while CI ran: rust install in 11 s (592 MB), the first build in 12 min 9 s. The built game ran Zoom's proof (under xvfb-run: the game needs a display even with --offscreen).

The native builds an instance plays are kept, and the three newest no instance plays (native_mods::prune, by when each was built or last used): switching a mod off and on again plays the kept build again without compiling. native/target/ stays for the next one.

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 .hnmod 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 .hnmod contains .rs and .toml files and may contain data files under mixins/<id>/data/ or a crate's data/. The launcher checks the bundle without running anything. Native code is trusted. The file is pinned by SHA-256 like every registry file, and the version, game and pins are the bundle's own horizon-native.json. revoked.toml takes a bad release down. A content mod's entry may turn native-mod once (never back): its .hmod versions stay published and are checked as .hmods, the index lists only its .hnmod versions, and the launcher's update keeps the player's .hmod (the game runs it until a native build has the mod, then skips it); removing the mod removes both.
  • 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: only client-side hooks do anything; the protocol is unchanged.
  • Dedicated servers: native mods are not built yet. Lifecycle hook call sites are ready for when the pipeline builds them (--no-default-features).

Left to do

  • A list of every native build, to delete the ones no instance needs.
  • A prebuilt horizon-mixins from its CI instead of building it on the player's computer.

Named hook catalog (draft)

native-hooks.json maps stable hook names and contract versions to the current source targets. A contract includes the signature, call timing and behavior. Keep an existing contract or add a new version when it changes incompatibly. The catalog does not cover helper methods or arbitrary private game code. Injections name them with hook and hook_version ("Named hook points" above); raw targets and the hooks metadata continue to work as before.

Reading server blocks (draft)

A native server tick hook can call native_server_block with its context, a dimension id and a block position. It returns the block name and state properties from a loaded chunk. It does not load a chunk or open a dimension. Reads outside that hook, from a stale context, from unavailable dimensions or outside the world bounds return an error.

Neighbour observations (draft)

native_neighbor_changed observes each authoritative server neighborChanged before the block handles it. It supplies the dimension, position, current state ID, changed block ID and moved flag. Resolve IDs with the level's registries during the hook; the hook does not load chunks or change the update queue.

Server block changes (draft)

native_block_changed observes a committed authoritative server Level.setBlock change before block side effects and neighbour updates. It supplies the dimension, position and old and new state IDs. It does not observe chunk loading or direct storage writes that bypass Level.setBlock.

Server commands and players

For mods that run where the world is simulated: a dedicated server, a LAN host, single player's server thread (crates/horizon-client/src/game/native_server.rs). The SMP mod of the official server is built on them (smp.md).

  • native_register_commands(commands): push a NativeCommand per usage, asked once when a world opens to players. A usage is words, then arguments <name:kind>, optional ones last in brackets: NativeCommand::new("pay <player:player> <amount:integer>"), NativeCommand::op("eco set <player:player> <amount:integer>", 3) for operators of level 3. Kinds: word, text (the rest of the line, last), integer, number, boolean, player, item, block_pos. The usages of one command merge into one tree, which every player receives after vanilla's (filtered by their level, as vanilla's is), so their chat box completes and colours it. A name vanilla has, or a usage that doesn't parse, is left out with a warning in the log.
  • native_perform_command(player, command, ran): a player typed a registered command (without its slash) and their level allows it, whether the world allows commands or not. The mod parses the line and sets ran. The player's state is swapped in for the call, as for their other actions, so native_player reads them; a registered command no mod takes answers as vanilla's unknown command.
  • Helpers: native_server_players() (uuid, name, level, dimension, position and game mode of each player on); native_tell(uuid, text), a system line to one player; native_broadcast(text), to every player and the server's log; native_feedback(text), the command's answer to the player whose command it is; native_world_dir(), the open world's folder, where a server mod keeps its data in a folder of its own so the world's backups carry it.
  • Items (API 9), of the acting player (inside native_perform_command, whose player is swapped in): native_held() (the main hand's item, count, and whether it is plain), native_count_items(item), native_carried_items(), native_take_items(item, count) (the main hand first, then as Inventory.add orders slots), native_room_for(item) and native_give_items(item, count) (only where it fits: nothing is dropped). They look at the 36 inventory slots and the off hand, never the armour, and only at plain stacks: no damage and no component (name, enchantment...), so a shop never takes a worn or enchanted item for a new one. Each returns how many it counted, took or gave; the player's screen follows as for any change to their inventory.
  • API 21: native_acting_player(), the player whose state is swapped in (inside native_perform_command, native_right_click_block, native_use_item_on, native_destroy_block...; None elsewhere); native_run_command(line), a command run as the server's console (level 4, no output), only where no player is swapped in (native_tick, a menu hook): a teleport to another level (execute in minecraft:the_nether run tp Alex 0 70 0) lands at the next tick's start. native_server_players() and the acting player carry health (0 once dead). native_action_bar(uuid, text), a line over a player's hotbar, which also reaches the acting player inside their hook (where native_tell reaches nobody).
  • native_right_click_block(pos, face, canceled) (API 21): a player's right click on a block, before the block's own use and the item's (ServerPlayerGameMode.useItemOn), once per hand. A mod that sets canceled stops both, without a swing; the player's client is sent the block back. With native_destroy_block it keeps players out of claimed land.
  • native_block_spread(dimension, kind, from, to, canceled) and native_piston_move(dimension, piston, direction, push, destroy, canceled) (API 22, on GameLevel, like the block observers): a fluid flowing or fire spreading (kind fluid or fire) from from into to, and a piston moving blocks once its structure resolved. Canceled, the block at to stays as it is, and the piston moves nothing. Fluids ask on every flow: answer from memory, fast.
  • native_random_tick(ticks, dimension, pos, state, handled) (API 33, on GameLevel): a block tickChunk random-ticks, before its own randomTick. Change the block with the level's native_set_block_property(ticks, pos, key, value, flags); set handled to skip the block's own random tick.
  • native_tick_block(ticks, dimension, pos, state, handled) (API 34, on GameLevel): a due scheduled tick of a block that is still there (ServerLevel.tickBlock), before its own tick; the same contract as native_random_tick.
  • native_explosion_detonate(dimension, center, blocks) (API 22): the positions an explosion is about to break, before any is hit; take out the ones to spare. Entities are hurt as before.
  • native_fill_bucket(stack, pos, canceled) and native_attack_entity(entity, kind, pos, canceled) (API 22): a player's bucket at a block, and their blow on an entity, with the acting player swapped in. Canceled, the bucket does nothing and the blow does no harm.

Mobs

API 37, crates/horizon-client/src/game/native_mobs.rs: helpers for server mods, for the level in place, on a server only (on a client they do nothing, and the readers give nothing). Each takes the dimension (minecraft:overworld) of the level it means, as native_set_block does, and is refused when another level is in place. The per-level tick hooks (API 23) run with a defined level in place; native_server_tick does not define one, so name the dimension and expect a refusal there.

  • native_spawn_mob(dimension, kind, pos, &NativeSpawn) spawns a mob and returns its id. NativeSpawn holds an optional name and health, and the no_ai, invisible, silent, invulnerable and no_gravity flags; its default is a plain mob. The mob is finalized as a natural spawn is, and kept when its chunk unloads. Nothing is spawned outside /summon's bounds, in a chunk that isn't loaded, or a monster on peaceful.
  • native_mob(dimension, id) and native_mobs(dimension) read the living mobs: id, dimension, kind, position, yaw, health and maximum health.
  • native_teleport_mob(dimension, id, pos) (as /tp: it dismounts and drops its path), native_push_mob(dimension, id, velocity) and native_set_mob_health(dimension, id, health) change one, and answer false for no such mob or a value that isn't finite. Health must be above 0.
  • native_kill_mob(dimension, id) kills as /kill does, with its loot; native_remove_mob(dimension, id) takes the mob away with no death and no loot (it is not saved either).
  • While the game ticks its mobs they are out of the level: from a hook inside that tick, a spawned mob joins after the tick, and the others read as gone.

Mod messages

API 40, crates/horizon-client/src/game/native_net.rs: bytes between a mod's server half and its client half, on the one custom payload channel horizon:mod_net. A vanilla client never gets them, and a vanilla server isn't sent any.

  • native_net_send_to_player(player, channel, data) on the server and native_net_send_to_server(channel, data) on the client send one. The channel is mod_id:name: the id [a-z0-9_], the name [a-z0-9_./-], each 1 to 64 characters; minecraft and horizon are the game's. The data is at most 30 000 bytes, and 32 messages a tick go each way per player. Ok means accepted: a player who left doesn't get it. Sending to the server is an error until the player has joined.
  • native_net_from_client(player, channel, data) and native_net_from_server(channel, data) hear them, all mods on every message: check the channel. What a client sent is checked by nothing, so treat it as a player's own input.
  • A player's message reaches the server's hook in the tick after it arrives, when the helpers can reach every player; a message to a player leaves at the end of the tick, from any server hook.

Server menus

API 10, crates/horizon-client/src/game/native_menus.rs: menus a server mod builds from ordinary items, which a stock client shows as a chest (generic_9x1 to generic_9x6); nothing is installed on the player's side.

  • native_open_menu(uuid, rows, title, items) opens one in place of the player's open menu (its window id); native_set_menu_items(uuid, items) changes what it shows; native_close_menu(uuid) closes it.
  • No click moves anything. Each reaches native_menu_clicked(uuid, window, slot, button, mode), and the client is sent the menu, its inventory and its cursor as they were. slot counts the menu's own slots first, then the player's inventory below it: native_menu_inventory_slot(size, slot) gives the inventory slot (0..9 the hotbar).
  • native_menu_closed(uuid, window): the player closed it, another menu opened, or the player left.
  • native_player_stacks(uuid): the player's 36 inventory slots, to show copies or offer them.
  • native_exchange_items(a, a_offer, b, b_offer): each offer is inventory slots, each with the exact stack it must still hold. Both offers leave their slots and go to the other player where they fit whole (onto matching stacks, then empty slots), or nothing moves and the error says why (an offer that changed, no room, a player gone or dead). A trade on these menus leaves the offered stacks in their owners' inventories until this one step, so closing the menu, leaving or dying moves nothing.

The menu hooks run with no player's state swapped in; native_player_stacks and native_exchange_items refuse a player whose state is (inside native_perform_command): call them from a menu hook or native_server_tick.