Files
horizon/docs/native-hooks-neoforge.md
rdbt 4dc1282f65 Merge pull request #9953 from Hiuoy/feat/9921-port-pack-events
Port pack: `events`, a mod's NeoForge listeners against the hooks
2026-11-06 21:04:33 +00:00

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 @SubscribeEvent methods map to hook names, which helps porting by hand. horizon-port-pack events lists 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

  1. Name. A hook with a NeoForge counterpart takes the event's name in snake case, its nested class included: ServerTickEvent.Pre → server_tick_pre, function native_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.
  2. 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.
  3. 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's Game (single player's server thread, LAN host, dedicated).
    • client: the window's Game.
    • both: both, with the Game's role available (native_window). Interactions are both: the client predicts, the server acts, and a mod that claims a use on one side only gets the two out of step.
  4. Cancel and results. Hooks never return; several mods hook the same point. A cancellable event takes canceled: &mut bool; one with a result (an InteractionResult) takes result: &mut Option<...>. Every mod is called; a mod checks *canceled itself when it should stand back. NeoForge's handlers likewise "can opt to explicitly receive cancelled events" and some events implement ICancellableEvent (Events, "Cancellable Events").
  5. Payload. The event's getters become parameters, by value or &. A hook gets &mut self only when the NeoForge listener may change the world at that point (server-side events); otherwise &self, and the doc does not call a &mut hook read-only.
  6. 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 catalog version. A stack of PRs that refines one hook lands in one release batch, under one NATIVE_HOOKS_API bump.
  7. API numbers are given at merge, not when a PR is opened: a PR says "API next" and takes main + 1 when 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

  1. This document (#4402), and the catalog fields: new hooks carry them, and #9921 filled them in for the core hooks that came before.
  2. 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_tick v1...) and the launcher check each against the catalog, with "hooks": N kept for old bundles. That is a separate change to native-mods.md; if agreed, rule 7 goes away.