Repository navigation
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.
| 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.
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.
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 anIllegalStateExceptionnaming 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 aConcurrentModificationException.
The registration is not tied to the plugin's lifecycle - nothing calls unregister() for you.
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.
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
SubscribeEventwhen 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.
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(...)answersfalse,dispatch(...)never runs, and the audience costs nothing. It is registered unconditionally precisely because that case is free; -
on a hybrid,
hasListeners(...)answersfalseall the same, anddispatch(...)never runs either. The first time the bus asks - a post, anECEventBus.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.
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.
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
IllegalStateExceptionnaming 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 -
RuntimeExceptionandLinkageErrorare caught, andThrowabledeliberately is not, so anOutOfMemoryErrorkeeps 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.
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.postcalls - wraps an event intoBukkitHookForgeEventonly whenBukkit.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 modernIEventBus, the adapter hands each@SubscribeEventmethod to the bus as a consumer, keeping the annotation'spriorityandreceiveCanceled. A consumer is used rather thanregister(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 anEntityJoinWorldEventhandler,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.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.
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.
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:
- the server's own registry through ECNmsUtils -
NMSUtils.get().blocks().getRegistryName(material)(theblocks()accessor arrived in ECNmsUtils 2.1.0),items().getRegistryName(stack)for an item,entities().getRegistryName(entity)for a mob - whereverNMSUtils.isSupported(). A modded block comes back asmodid:name; - Bukkit's
Keyed- from 1.13 for blocks and items, from 1.14 for entity types; -
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).
- 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
OreDictionaryand the modded item tags of a hybrid.
EverNifeCore · Home · made by Petrus Pradella
Getting Started
Commands & Text
Player Data & Storage
- PlayerData & PDSections
- Accounts
- Storage Backends
- Inline Backends for Plugins
- Block Data (SVWorldDataManager)
- Legacy Data Migration
- Cooldowns
Config & Minecraft Systems
- Configuration
- Logging & Debug Modules
- Events
- Regions
- Groups
- Notifications
- NPCs
- Logic
- Quests
- Provenance
- Scheduler & Threading
- Items & NBT
- Screens
- Dialogues
- Menus
- Integrations
- Economy
- Boosters
- Deliveries
- Audit Log
- Dynamic Values
- Player Ignore
- Version Compatibility
Architecture & Reference