8.3 KiB
Native hook points, modelled on NeoForge 26.3 (proposal)
Status: proposal, in use for new hooks: they take the names, sides and catalog fields below. Checked against native-hooks.json on main
(427 hooks, NATIVE_HOOKS_API 409) on 5 November 2026.
Native mods hook empty functions in crates/horizon-client/src/game/native_hooks.rs, listed by name in
native-hooks.json (native-mods.md). So far each hook was designed on its own, and open PRs
number their API versions over each other. This proposes one
reference for every new hook point: the NeoForge 26.3 event that does the same thing.
Why NeoForge:
- Its events already answer the questions each of our hooks asks again: where exactly it fires, on which side, what it can cancel, what it is handed.
- Mod authors know them. A Java mod's
@SubscribeEventmethods map to hook names, which helps porting by hand.horizon-port-pack eventslists a mod's listeners with the hooks for each (modpacks.md), for a human to review; it doesn't translate them.
What we take is event names and their documented contracts, from NeoForge's public user documentation (https://docs.neoforged.net/) only, each claim below citing the page it comes from, as CONTRIBUTING.md allows. Nobody reads, copies, translates or paraphrases NeoForge's source, its patches included: where a hook fires is decided from vanilla 26.3's own methods, and the hooks stay Horizon's own Rust.
Rules
- Name. A hook with a NeoForge counterpart takes the event's name in snake case, its nested class
included:
ServerTickEvent.Pre→server_tick_pre, functionnative_server_tick_pre;PlayerInteractEvent.RightClickBlock→right_click_block. A hook with no counterpart (the minimap, the mods list, the item panel) keeps a Horizon name. - Firing point. It fires in Horizon's port of the vanilla method where the documented event happens
(the tick starts, a block is broken), at the point vanilla's own steps make that true. The catalog names the vanilla method, so a
reviewer can check it with
tools/rea_vanilla.py. - Side. NeoForge documents that "some events are only fired on one side"
(Events, "Sided Events"); the rest fire on whichever
side runs the method. Horizon has one binary with several
Games (the window, the server thread, a dedicated server), so each hook says its side:server: the server'sGame(single player's server thread, LAN host, dedicated).client: the window'sGame.both: both, with theGame's role available (native_window). Interactions areboth: the client predicts, the server acts, and a mod that claims a use on one side only gets the two out of step.
- Cancel and results. Hooks never return; several mods hook the same point. A cancellable event takes
canceled: &mut bool; one with a result (anInteractionResult) takesresult: &mut Option<...>. Every mod is called; a mod checks*canceleditself when it should stand back. NeoForge's handlers likewise "can opt to explicitly receive cancelled events" and some events implementICancellableEvent(Events, "Cancellable Events"). - Payload. The event's getters become parameters, by value or
&. A hook gets&mut selfonly when the NeoForge listener may change the world at that point (server-side events); otherwise&self, and the doc does not call a&muthook read-only. - Frozen once merged. Name, parameters and call site never change after a merge (as today). A changed
contract is a new hook (
..._v2) or a new catalogversion. A stack of PRs that refines one hook lands in one release batch, under oneNATIVE_HOOKS_APIbump. - API numbers are given at merge, not when a PR is opened: a PR says "API next" and takes
main + 1when it merges, so open PRs stop colliding.
Catalog fields
Each native-hooks.json entry gains three optional fields:
{
"name": "server_tick_pre",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_server_tick_pre",
"neoforge": "ServerTickEvent.Pre",
"side": "server",
"cancellable": false
}
neoforge is absent for Horizon-only hooks. horizon-mixins ignores fields it doesn't know, so this needs no
tool change; the launcher and the docs can list them.
The hooks we have
Main's catalog (native-hooks.json) is the list; this page does not copy it. A hook carries side,
cancellable and neoforge: the event's name, none (…) when NeoForge has no counterpart, and a note in
brackets where the contract differs (place_block only hears). A hook with no neoforge key hasn't been checked
yet (#9921). Hooks main already had under a NeoForge-like name: server_tick, level_tick_pre,
level_tick_post, right_click_block, block_entity_tick, destroy_block, place_block, attack_entity.
Per rule 6 none of them is renamed: a rename is a new hook with a new name, if ever wanted.
Hooks added since their own PRs carry their event here too.
Data reload
data_pack_files is AddReloadListenerEvent: server-side and not cancellable, fired while the server
assembles its data tables — a world's first open and every /reload — before the recipes, tags, loot and
advancements are built. A mod adds (path, bytes) pack files to the hook's out parameter; they join the
load below the world's own data packs, so a pack's file for the same path still overrides them. A mod that
adds nothing leaves the tables as they were.
add_server_reload_listeners is AddServerReloadListenersEvent with a SimpleJsonResourceReloadListener:
a mod names a folder under data/<namespace>/, and on each data load, before server_data_loaded,
server_reload_listener_apply hands it the folder's JSON files (the top pack's for each id) as apply
does. Only JSON folders: a mod parses the values itself, and a listener that merges every pack's file for one
id is not here.
Next hooks
Every hook this section proposed has landed, some of it under the vanilla call's name (rule 6 keeps those
names): left_click_block, use_item (RightClickItem), interact_entity (EntityInteract),
player_tick_pre and player_tick (PlayerTickEvent.Pre and .Post, both server-side), client_tick_pre /
_post (ClientTickEvent.Pre / .Post), entity_join_level, living_incoming_damage, living_death, and
player_container_open / _close. New hooks take their NeoForge name when they are built (rule 2).
Capabilities
Hoppers, comparators and other mods should not read a fixed slots field. NeoForge's documentation lists
Capabilities.Item.BLOCK ("automation-accessible inventory of a block") with ResourceHandler<ItemResource>,
and Capabilities.Energy.BLOCK with EnergyHandler
(Capabilities). A Horizon
block_capability(pos, kind, side, out) hook could fill an out parameter with a handler object, so new kinds
(fluids, energy) are new kinds, not new fields on a struct, and older mods keep compiling. So far main has one
hook per kind instead: block_entity_energy (Capabilities.Energy.BLOCK), item_container
(Capabilities.Item.ITEM), item_fluid_tank (Capabilities.FluidHandler.ITEM) and item_energy_buffer
(Capabilities.Energy.ITEM). Menus and screens
are not modelled here: main already has menu_clicked, menu_closed, screen* and block_entity_container,
and any change to them gets its own proposal with its own citations.
Rollout
- This document (#4402), and the catalog fields: new hooks carry them, and #9921 filled them in for the core hooks that came before.
- The table above, one small PR per hook or family, each with a test that it fires at the vanilla method named here.
Open questions
- Snake-case NeoForge names without a
neoforge_prefix (agreed in the comments). Both-side hooks pass the role as a parameter (agreed). - Rule 7 renumbers every open PR on each merge. Every catalog entry already has a name and
version, so a bundle could list the hooks it uses (random_tickv1...) and the launcher check each against the catalog, with"hooks": Nkept for old bundles. That is a separate change tonative-mods.md; if agreed, rule 7 goes away.