Skip to content

Forge Hybrids

Petrus Pradella edited this page Aug 15, 2026 · 3 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 void registerListener(Plugin plugin, ECListener listener);
public static void 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.

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, and only that one. 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, defaultBus, listener) one register per bus handed in
ArclightForgeListener Arclight.registerForgeEvent(plugin, defaultBus, listener) only for buses that are a net.minecraftforge.eventbus.api.IEventBus; anything else is skipped in silence
ModernMohistForgeListener MohistEventBus.register(defaultBus, listener) one register per bus handed in
MohistForgeListener scans your listener's declared methods for @SubscribeEvent and routes them through Mohist's own Bukkit wrapper event ignores the buses entirely - it delegates to the no-bus form

Two consequences worth carrying:

  • Passing a bus does not guarantee it is used. On Mohist the parameter is discarded by design (that hybrid mirrors Forge events into Bukkit rather than exposing a bus to register on); on Arclight a bus of an unexpected type is dropped without a word.
  • 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 three 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

One probe per hybrid flavor, first match wins, and the class each looks for is the API class that flavor ships:

Order Class probed Adapter Eras the branch claims
1 io.github.crucible.api.CrucibleEventBus CrucibleForgeListener 1.7.10
2 io.izzel.arclight.api.Arclight ArclightForgeListener 1.12.2, 1.16.5, 1.20.x
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.

The probe answers flavor, not era, and that gap is real. One branch can cover three Minecraft eras (Arclight's does), so a server whose flavor matches still gets an adapter that may speak the wrong era's vocabulary. That is not hypothetical - it is what a 1.21.1 Arclight does, and it is 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 call completed (see below) present after enable, gone after shutdown
Arclight 1.21.1 Arclight 1.0.1-8ec9529 ArclightForgeListener fails - see below present after enable, gone after shutdown
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.

The two Mohist results are weaker, and the difference is worth stating. MohistForgeListener scans the listener for @SubscribeEvent methods and does nothing when there are none - and the probe listener had none. So what those two prove is that the adapter builds on both eras (it resolves BukkitHookForgeEvent.getEvent, resolves the era's @SubscribeEvent, and registers its Bukkit bridge) and that the call completes. A Forge event actually arriving at a @SubscribeEvent method was not measured.

Arclight 1.21.1 does not work, and the reason is the era

Arclight 1.21.1 runs on NeoForge, where net.minecraftforge.* no longer exists. All three by-name lookups the adapter needs fail there, each differently:

What it reaches for On Arclight 1.21.1 How it fails
Arclight.registerForgeEvent class is there, but the lookup dies inside Arclight's own class remapper a raw NoClassDefFoundError: org/bukkit/plugin/Plugin - the guard never runs, so the message blames a type that is not the problem
net.minecraftforge.common.MinecraftForge (for EVENT_BUS) absent would be the guarded IllegalStateException, but the failure above happens first
net.minecraftforge.eventbus.api.IEventBus absent isModernEventBus answers false, so the varargs overload registers nothing, in silence

Nothing was changed in response: which flavor a branch claims, and which vocabulary an adapter speaks, are design decisions. What this page can do is not promise the route works there. It does not.

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.


See also

Clone this wiki locally