Skip to content

Forge Hybrids

Pétrus Pradella edited this page Oct 25, 2026 · 14 revisions

Forge Hybrids

What this page covers: what EverNifeCore does on a hybrid Bukkit+Forge server - the API that hands a Forge listener of yours to the server's own Forge bus, the forge audience that sits on the event bus, how the adapter for the running hybrid is picked, and - separately, because it is not the same claim - which hybrids that has actually been run on.

A hybrid runs Bukkit plugins and Forge mods in one process: Cauldron/Crucible on 1.7.10, Mohist, CatServer, Arclight. On everything else - Spigot, Paper, any vanilla-derived server - all of this is inert. No Forge class is loaded, nothing is logged, and the one entry point that could act refuses with a message instead. That is the case that runs on almost every server, so it is the one that had to be free.

The code lives in the minecraft module: br.com.finalcraft.evernifecore.minecraft.listeners.forge for the inbound half, and br.com.finalcraft.evernifecore.minecraft.eventbus.McForgeAudience for the outbound one.


Two directions, and they are not symmetrical

Direction What it means Entry point State
Inbound a Forge event reaches a listener you wrote ForgeListener.registerListener(...) works on the hybrids measured below
Outbound an ECEvent posted on the bus reaches the Forge side the forge audience registered and asked on every post - nothing crosses yet

Read that table before designing anything around it. The inbound half is a working route with a public API; the outbound half is a seam that is in place, is exercised, and currently ends in a log line.


Inbound: registering a Forge listener from a Bukkit plugin

ForgeListener is the whole public surface - three static methods, no instance to hold:

public static boolean isAvailable();
public static ForgeRegistration registerListener(Plugin plugin, ECListener listener);
public static ForgeRegistration registerListener(Plugin plugin, ECListener listener, Object... eventBus);

plugin is org.bukkit.plugin.Plugin; listener is EverNifeCore's own ECListener. The buses are Object... on purpose: the caller hands over a bus whose type EverNifeCore never names, and never has to - see resolved by name.

Ask isAvailable() first. Both overloads throw when there is no hybrid behind the server, which is the common case:

//in your plugin's enable; `this` is the Bukkit Plugin doing the registering
if (ForgeListener.isAvailable()) {
    ForgeListener.registerListener(this, new MyForgeListener());
}
java.lang.IllegalStateException: Tried to register ForgeEvents but there is no IForgeListener
available for EverNifeCore on this Server.

isAvailable() is also what runs the detection, so the first caller pays for building the adapter and everyone after it reads a field.

Taking a listener back: ForgeRegistration

Both overloads return a ForgeRegistration (same package). Keep it, and call unregister() in your shutdown - the listener stops receiving Forge events on every bus that registration reached, so a plugin with Forge listeners no longer needs a server restart to go away:

private ForgeRegistration forgeListener;

//enable
if (ForgeListener.isAvailable()) {
    forgeListener = ForgeListener.registerListener(this, new MyForgeListener());
}

//shutdown
if (forgeListener != null) {
    forgeListener.unregister();
}
  • Only the first call acts; any later one is a no-op, even when the first one threw.
  • Every bus is attempted, even when one of them fails; the first failure is then thrown with the others attached as suppressed.
  • Crucible, Arclight and modern Mohist call each reached bus's own unregister(Object). A bus that declares no such member is refused with an IllegalStateException naming its type - the other buses are still unregistered.
  • Mohist 1.16.5 calls the bus's own unregister(Object) for each consumer it subscribed. Mohist 1.12.2 removes the handlers it put in its own table. That table is safe to change while events are being posted on another thread: a handler removed mid-post may still receive that one event, never a ConcurrentModificationException.

The registration is not tied to the plugin's lifecycle - nothing calls unregister() for you.

The listener is Forge's shape, not EverNifeCore's

The class implements ECListener, but the handler methods are ordinary Forge handlers:

public class MyForgeListener implements ECListener {

    //@SubscribeEvent and the parameter type both come from the Forge this server runs.
    //EverNifeCore names neither and has neither on its classpath - they are yours to import.
    @SubscribeEvent
    public void onSomeForgeEvent(SomeForgeEvent event) {
        //...
    }
}

The annotation Forge declares changed package between eras, and both are accepted: net.minecraftforge.eventbus.api.SubscribeEvent from 1.16.5 on, and net.minecraftforge.fml.common.eventhandler.SubscribeEvent before it.

Your plugin is the one that needs those types at compile time. EverNifeCore does not provide them and does not stand between you and them.

Which overload, and what each adapter does with it

The no-bus overload uses the era's default bus - net.minecraftforge.common.MinecraftForge.EVENT_BUS, or net.neoforged.neoforge.common.NeoForge.EVENT_BUS where the Forge side is NeoForge - plus, on 1.7.10 only, FML's own FMLCommonHandler.instance().bus(), where that era posts TickEvent and FML's PlayerEvent family. From 1.8 on FML hands out MinecraftForge.EVENT_BUS itself (read in the 1.12.2 bytecode), so there is one bus and it is registered once. The varargs overload takes the buses you already hold. What happens next depends on the hybrid, and the differences matter:

Adapter registerListener(plugin, listener) registerListener(plugin, listener, buses...)
CrucibleForgeListener CrucibleEventBus.register(plugin, bus, listener) for MinecraftForge.EVENT_BUS and for FML's bus one register per bus handed in
ArclightForgeListener Arclight.registerForgeEvent(plugin, defaultBus, listener) one registerForgeEvent per bus handed in; a bus that is not a net.minecraftforge.eventbus.api.IEventBus is refused with an IllegalArgumentException before anything is registered
ArclightNeoForgeListener NeoForge.EVENT_BUS.register(listener), through net.neoforged.bus.api.IEventBus one register per bus handed in; a bus that is not a net.neoforged.bus.api.IEventBus is refused with an IllegalArgumentException before anything is registered
ModernMohistForgeListener MohistEventBus.register(defaultBus, listener) one register per bus handed in
MohistForgeListener scans your listener's declared methods for @SubscribeEvent. Where MinecraftForge.EVENT_BUS is a net.minecraftforge.eventbus.api.IEventBus (1.16.5) each one is subscribed on that bus with addListener(priority, receiveCanceled, eventType, consumer); on 1.12.2 they are routed through Mohist's own Bukkit wrapper event the same, once per bus handed in: a modern bus (a mod's own, such as Pixelmon's) gets the consumers itself; a bus that is not an IEventBus (1.12.2) is heard through the wrapper event, wired once however many such buses were passed. An empty list means the no-bus form

Two consequences worth carrying:

  • A bus you pass is the bus you get. Every adapter subscribes on the buses handed in. On Mohist 1.12.2, whose buses take no consumer, that means hearing them through Mohist's Bukkit mirror; both Arclight adapters refuse a bus of an unexpected type with an IllegalArgumentException, before registering on any of them.
  • Mohist is the adapter that does its work in the constructor. It wires its Bukkit bridge there, so it is the one that can refuse before you ever call it - naming SubscribeEvent when neither annotation resolves. The other four build without touching a Forge type and refuse only when used, each naming the class it could not find.

Outbound: the forge audience

McForgeAudience is an ECNativeAudience registered under the name forge. The Bukkit entry point adds it at enable and removes it at shutdown, next to the Bukkit one - see Architecture Overview.

Its gate is the hybrid detection ForgeListener already does, and deliberately the only one - a second probe could disagree with the first. What that detection decides is only whether to speak: the gate itself answers false on every server, because an audience that cannot deliver has no listeners, whatever the Forge side registered. So:

  • on a server with no Forge, hasListeners(...) answers false, dispatch(...) never runs, and the audience costs nothing. It is registered unconditionally precisely because that case is free;
  • on a hybrid, hasListeners(...) answers false all the same, and dispatch(...) never runs either. The first time the bus asks - a post, an ECEventBus.hasListeners(...), a listener watch being evaluated - the audience says this, once per run:
[ECEventBus] This server has a Forge side and the 'forge' audience is registered, but no event can
reach the Forge bus yet: posting one needs a compiled carrier extending this era's Forge Event class,
and none is built. Bukkit listeners are unaffected. This is said once.

Answering the hybrid flag instead would be worse than useless: it would hold every listener watch open and make every gated event be built - on exactly the hybrid servers this project runs on - for a bus nothing reaches. dispatch(...) stays a no-op rather than being removed, so a caller that bypasses the gate loses nothing.

Why nothing crosses, and why it is not a matter of effort. A Forge bus takes a subclass of its era's Event - and Event is a class. Reflection can call a bus and instantiate a class that exists; it cannot define a subclass, and Proxy only implements interfaces. Each Forge era declares that base under a different name, so the route out is one compiled carrier per era, not one reflective call. Until such a carrier exists, the audience is a seam that is wired, asked and idle.

Nothing about this affects Bukkit listeners: the Bukkit audience mirrors as it always did, and the forge audience runs after it.


How the adapter is chosen

The flavor comes from ServerSoftware - one probe per hybrid, first match wins, the class each looks for being the API class that flavor ships - and the adapter from the flavor:

Order Class probed Adapter Eras the branch claims
1 io.github.crucible.api.CrucibleEventBus CrucibleForgeListener 1.7.10
2 io.izzel.arclight.api.Arclight ArclightNeoForgeListener where the era check says NeoForge, ArclightForgeListener otherwise NeoForge 1.21.1; every Arclight era that still ships net.minecraftforge
3 com.mohistmc.forge.MohistEventBus ModernMohistForgeListener 1.20.x
4 com.mohistmc.api.event.BukkitHookForgeEvent MohistForgeListener 1.12.2, 1.16.5
5 catserver.api.bukkit.ForgeEventV2 MohistForgeListener 1.16.5 - CatServer is served by the Mohist bridge and has no adapter of its own

No match means no adapter, and isAvailable() answers false for the rest of the run. Youer is detected (ServerSoftware.YOUER) but ships none of these bridges, so it gets no adapter.

A probe answers flavor, and flavor alone is not enough. One branch can cover several Minecraft eras (Arclight's does), and those eras disagree on which package the Forge types live in - so a server whose flavor matches can still get an adapter that speaks the wrong vocabulary. The Arclight branch is where that bit: it now asks the era too, handing out the NeoForge adapter where net.neoforged.bus.api.IEventBus is on the runtime and net.minecraftforge.common.MinecraftForge is not. The other four branches still answer flavor only, so the gap is closed where it was measured and open everywhere else - measured below.

The detection never throws. A platform that only half-matches would otherwise fail this class's initialization and turn every later call - isAvailable() included - into a NoClassDefFoundError; failing the detection instead costs the Forge route and nothing else.


Everything here is resolved by name

No Forge type is compiled into EverNifeCore. Every class, field and method on this route starts life as a string and is looked up at runtime, which is what lets a single adapter serve eras that disagree about types - MinecraftForge.EVENT_BUS is a cpw.mods.fml.common.eventhandler.EventBus on 1.7.10 and a net.minecraftforge.eventbus.api.IEventBus from 1.16.5 on, and a by-name read hands back either one as an opaque handle.

The price is that the compiler checks none of it. Not a class name, not a member name, not an arity. A typo, a renamed method or an era that moved a type is a failure that appears only when a server of that era executes the line - never in a build, never in a unit test. See Version Compatibility for why the Bukkit side of the core refuses reflection for exactly this reason; the Forge side pays the price knowingly, and the mitigations are what is left:

  • a lookup that comes back empty is refused with an IllegalStateException naming the class and the member it wanted, and telling the reader to report the server brand and version;
  • arity is checked before invoking, so a by-name lookup cannot quietly bind to an overload taking something else and fail deep inside the call;
  • a lookup that throws instead of answering is refused the same way - RuntimeException and LinkageError are caught, and Throwable deliberately is not, so an OutOfMemoryError keeps its own type instead of being filed as a quirky hybrid;
  • the refusal is thrown at call time, never at class-initialization time.

Which is the honest limit of it: a good message where the lookup answers, and nothing where the server breaks the lookup itself. The Arclight case below is exactly that hole.


What has actually been run on a real server

Because none of this is compile-checked, a real server of the right era is the only evidence there is. This table is that evidence, and nothing in it is inferred. The brand column is what the server reported about itself, which is not always what its distribution is called: the 1.7.10 hybrid tested here ships Crucible's API classes and reports itself as Cauldron.

Server tested Brand it reported Adapter chosen Inbound registration forge audience
1.7.10 hybrid Cauldron 1.7.10-staging-9985c735 CrucibleForgeListener completed end to end present after enable, gone after shutdown
Mohist 1.12.2 Mohist git-Mohist-1.12.2-2d59d5a MohistForgeListener call completed (see below) present after enable, gone after shutdown
Mohist 1.16.5 Mohist 1.16.5-8c7caaf7 MohistForgeListener completed end to end, events delivered - straight on the Forge bus (2026-10-05, see below) present after enable, gone after shutdown
Arclight 1.21.1 Arclight 1.0.1-8ec9529 (NeoForge 21.1.192) ArclightNeoForgeListener (2026-10-05) completed end to end, events delivered (2026-10-05, see below) registered; says its idle notice once, as on every reachable hybrid
Paper 1.21.1 Paper 1.21.1-133-3cb8529 none - not a hybrid refuses, with the message above registered and completely inert

On all five the server log was clean of exceptions from the plugin, and the audience was on the bus after enable; on the four hybrids it was also confirmed gone after shutdown.

The 1.7.10 result is the strong one. The whole chain ran by name on a real Cauldron: MinecraftForge.EVENT_BUS was read, CrucibleEventBus.register resolved with three parameters and was invoked, and no NoSuchFieldError or NoClassDefFoundError appeared.

Mohist 1.16.5 does not go through Mohist's mirror, because the mirror was measured silent (2026-10-05). On Mohist 1.16.5-8c7caaf7 the bytecode of com.mohistmc.forge.ForgeEventHandler.invoke

  • the hook the patched EventBus.post calls - wraps an event into BukkitHookForgeEvent only when Bukkit.getServer().isPrimaryThread() answers false. Everything posted on the main thread, which is nearly all of gameplay, never reaches Bukkit: a listener registered through the mirror saw 0 of the entities summoned and 0 of a real player joining, with no error anywhere. So where the default bus is a modern IEventBus, the adapter hands each @SubscribeEvent method to the bus as a consumer, keeping the annotation's priority and receiveCanceled. A consumer is used rather than register(listener) so the bus has no handler class to generate against a plugin's classloader. Measured the same day on that server: two summoned entities delivered to an EntityJoinWorldEvent handler, unregister() run in the plugin's shutdown, no stack frame in the whole log.

Two limits of that route: a handler over a GenericEvent is refused by the bus (it wants addGenericListener) and is logged as a failed registration, and events arrive on whichever thread posted them - Forge's own rule, where the mirror used to hand over only off-thread ones.

The Mohist 1.12.2 result is weaker. That era's bus takes no consumer, so the mirror is still the route there: the adapter builds (it resolves BukkitHookForgeEvent.getEvent, the era's @SubscribeEvent, and registers its Bukkit bridge) and the call completes. A Forge event actually arriving at a @SubscribeEvent method was not measured on 1.12.2, and whether that build's mirror has the same main-thread gap is unknown.

Arclight 1.21.1 runs NeoForge, and here is what was measured (2026-10-05)

Arclight 1.0.1-8ec9529 runs MC 1.21.1 on NeoForge (21.1.192 on the server tested), where net.minecraftforge.* no longer exists: net.neoforged.bus.api.IEventBus loads, net.minecraftforge.common.MinecraftForge and net.minecraftforge.eventbus.api.IEventBus do not.

Why Arclight's own entry point is not used there. javap on that server's jar gives

io.izzel.arclight.api.Arclight
  registerForgeEvent(org.bukkit.plugin.Plugin, net.minecraftforge.eventbus.api.IEventBus, java.lang.Object)
  registerModEvent(org.bukkit.plugin.Plugin, java.lang.Object, java.lang.Object)

The first still names the Forge bus type this runtime does not carry, and that poisons the whole class for a by-name lookup: listing its declared methods dies inside Arclight's own class remapper (a raw NoClassDefFoundError: org/bukkit/plugin/Plugin out of ClassLoaderRemapper.tryDefineClass, measured 2026-08-22), before registerModEvent can be picked. And the bytecode of the NeoForge implementation (NeoForgeArclightServer.registerModEvent) does one thing with a net.neoforged.bus.api.IEventBus: call its register(Object). So ArclightNeoForgeListener does exactly that, on the bus itself, and never touches the Arclight class:

What it reaches for By name
the default bus net.neoforged.neoforge.common.NeoForge.EVENT_BUS
registering net.neoforged.bus.api.IEventBus.register(Object)
unregistering net.neoforged.bus.api.IEventBus.unregister(Object)

Both members are resolved on the interface, not on the class behind the bus: NeoForge loads its bus as a named module, and nothing says the implementing class's package is open to a plugin.

Measured on that server. A plugin listener annotated with net.neoforged.bus.api.SubscribeEvent over EntityJoinLevelEvent was registered through ForgeListener.registerListener(plugin, listener); two entities summoned from the console were both delivered to it; ForgeRegistration.unregister() ran in the plugin's shutdown; and the whole run's log carries no stack frame at all.

What a listener has to be. Annotated for NeoForge (net.neoforged.bus.api.SubscribeEvent) and compiled against NeoForge's event types. A listener written for net.minecraftforge is not translated: on 1.21 a plugin picks its listener by mod loader, not by Minecraft version alone.

Never run anywhere

Two of the five detection branches have never been executed by any server:

  • com.mohistmc.forge.MohistEventBus -> ModernMohistForgeListener (a Mohist on 1.20.x);
  • catserver.api.bukkit.ForgeEventV2 -> MohistForgeListener (CatServer).

They are written, they compile, and they are unproven. Given that nothing on this route is compile-checked, treat "unproven" as "unknown", not as "probably fine".

And the outbound direction is unproven everywhere by construction: the audience registered and was asked on all four hybrids, and on all four the only thing that happened was the log line above.


Modded blocks, items and mobs in the regions

A region flag filters by key (only=[...], actors=[pixelmon:*]), and on a hybrid the key of a modded block has to name the mod. McActionContexts.key(Material) therefore climbs a ladder, first rung that answers wins:

  1. the server's own registry through ECNmsUtils - NMSUtils.get().blocks().getRegistryName(material) (the blocks() accessor arrived in ECNmsUtils 2.1.0), items().getRegistryName(stack) for an item, entities().getRegistryName(entity) for a mob - wherever NMSUtils.isSupported(). A modded block comes back as modid:name;
  2. Bukkit's Keyed - from 1.13 for blocks and items, from 1.14 for entity types;
  3. bukkit:<constant> (the enum name, lowercased) - a name Bukkit made up, and its namespace says so.

A rung that throws (a mod material the bridge cannot map back to a block) falls to the next one. On 1.7.10 the entity registry writes no namespace: a bare Pig is read as the platform's own and a modid.Name as the mod's (minecraft:PrimedTnt, modid:Name). Before 1.13 one block key covers every meta (minecraft:wool).

See also

  • Events - the bus, the audiences, and how to write one of your own.
  • Architecture Overview - where the audiences are added and removed in the Bukkit startup sequence.
  • Version Compatibility - the eras this core spans, and why reflection is refused on the Bukkit side.
  • Gotchas & Pitfalls - the short version of the by-name trap.
  • Version Compatibility - NMS - the embedded ECNmsUtils bridge, whose item dictionary reads Forge's OreDictionary and the modded item tags of a hybrid.

Clone this wiki locally