Cross Mod Functionality - coloursofnoise/Resources GitHub Wiki
There are some situations where you may want to use or extend functionality from another mod. This page will describe some of the common methods and recommendations based on your use case.
Before dealing with other mods, it's helpful to understand how Everest handles dependency loading. Dependencies are defined in a mod's everest.yaml file. They can be specified as required or optional depending on the tags used. Required dependencies must be loaded before your mod will be loaded. Optional dependencies will be ignored if not enabled, but will be treated as required if they are enabled (more detailed info here).
In general, you want to limit the number of required dependencies your mod has to keep it lightweight and flexible.
An important thing to note about Celeste modding is that the usual convention of access modifiers to mark code as accessible or extensible does not apply. Just because a method is marked as public
does not mean that it is safe to be used outside of the mod -- in many cases, the access modifiers are holdovers from the original game, which was not designed to be referenced from other assemblies. In the same way, marking a method as private
does not mean another mod cannot access it. Tools like reflection can be used to get around these restrictions.
Therefore, most implementations of cross-mod functionality are considered unsafe. Code defensively when possible and be prepared to make fixes if necessary. There is no guarantee that a method will always function the same way or even have the same signature, although for this reason it's recommended to avoid changing interfaces when possible. The exception is if the mod creator explicitly marks an interface as an API, which essentially is a "contract" that guarantees the signature and function will not change. However, it's up to the mod creator to honor that contract.
Below are several different ways to implement cross-mod functionality, roughly in order from most to least recommended.
It may seem obvious, but the easiest, safest way to implement any new feature involving another mod is to add it to that mod. If you just want to e.g. create an entity that is similar to another modded entity or slightly tweak an existing entity, try reaching out to the creator about adding it to their mod directly. Many older mods are maintained by the Communal Helper organization and are open to contributions and requests.
ModInterop is a MonoMod feature and the closest thing we have to an "official" API. One mod creates a set of methods to export, and then other mods can import them as delegates using MonoMod.Interop
. If the dependency is disabled, the delegate will be null, otherwise you can invoke it to access the other mod's features without adding a direct dependency.
Of course, a mod must first create the API for others to use it. Consider reaching out to a mod creator about adding a ModInterop API if there are fields or methods that you need to access inside of your mod. You can also consider it for your own mod! Just remember, an API is a contract. Modders who use your API will expect it to work until at least the next major version of your mod. It's also recommended to document your API, at minimum with the version each method was added.
Here is an example using an excerpt from the Communal Helper API vs. how we could use it in another mod:
// Communal Helper Interop
[ModExportName("CommunalHelper.DashStates")]
public static class DashStates {
public static int GetDreamTunnelDashState() => DreamTunnelDash.StDreamTunnelDash;
}
// MyMod.Module
[ModImportName("CommunalHelper.DashStates")]
public static class CommunalHelperImports {
public static Func<int> GetDreamTunnelDashState;
}
// MyMod.Entity
bool inDreamTunnelState = player.StateMachine.State == (CommunalHelperImports.GetDreamTunnelDashState?.Invoke() ?? -1);
Communal Helper exports the field as a method, which lets our mod import it as a delegate. We can then use some logic to return a default value if Communal Helper was not loaded and the delegate is null, but otherwise we can call the delegate to get the original field.
âšī¸ To use a ModInterop API, you should add an optional dependency for the mod with the version that the interface was added. In the above example, GetDreamTunnelDashState
was added in Communal Helper 1.13.3, so that would be the minimum version we use in our everest.yaml.
Here is a list of public ModInterop APIs (feel free to add or update your own):
The most straightforward way to use a mod interface is to reference the other mod directly:
// using namespace Celeste.Mod.CommunalHelper.DashStates;
bool inDreamTunnelState = player.StateMachine.State == DreamTunnelDash.StDreamTunnelDash;
This is the same example as before, except it references the original field directly. Direct references are limited to public
interfaces (or protected
from a derived class). If you want to use something private
or internal
, you will need to use reflection.
The code is simpler, but requires us to add an assembly reference for Communal Helper to the project. If your source is public, anyone who builds the project (including any autobuild workflow) will need the added dependency as well. Distributing a dependency directly is discouraged (and, depending on the license, illegal) unless you use a tool like mono-cil-strip to remove the source code but still allow building against the DLL. If the interface is changed in a future update, you will have to update your code and the stripped DLL.
A common implementation of this check looks like this:
// MyModule.Load()
// communalHelperLoaded -> public static bool
EverestModuleMetadata communalHelper = new() {
Name = "CommunalHelper",
Version = new Version(1, 13 ,3)
};
if (Everest.Loader.TryGetDependency(communalHelper, out _) {
communalHelperLoaded = true;
}
// MyModule.Entity
if (communalHelperLoaded) {
FunctionThatReferencesCommunalHelper();
}
Checking the status of the dependency in MyModule.Load()
lets us use communalHelperLoaded
as a wrapper for any references we make, since optional dependencies will load before our mod if enabled. Note that we can't reference Communal Helper directly in this if-statement -- methods are compiled fully as they are entered, so we can't invoke any method that contains a reference to another assembly until we pass the loaded check.
Reflection is a tool that lets you dynamically create and use types, methods, etc. at runtime. ModInterop and DynamicData use reflection internally.
You can use reflection to access things marked as internal
or private
, or even avoid direct assembly references entirely. However, non-public interfaces and implementation details have a high risk of changing, although reflection also allows you to add safeguards if an interface has changed. For example, if you use reflection to search for a method and it no longer exists, it will return null, which you can check for before calling the method.
Here is an example:
// MyModule.Load()
// communalHelper -> EverestModuleMetadata from previous section
// dreamTunnelDashState -> public static FieldInfo
if (Everest.Loader.TryGetDependency(communalHelper, out EverestModule communalModule) {
Assembly communalAssembly = communalModule.GetType().Assembly;
Type dreamTunnelDash = communalAssembly.GetType("Celeste.Mod.CommunalHelper.DashStates.DreamTunnelDash");
dreamTunnelDashState = dreamTunnelDash.GetField("StDreamTunnelDash", BindingFlags.Public | BindingFlags.Static);
}
// MyMod.Entity
bool inDreamTunnelState = player.StateMachine.State == MyModule.dreamTunnelDashState?.GetValue(null) ?? -1;
As you can see, this lets us access a field without any reference to the assembly at all, in a similar fashion to ModInterop. However, the code becomes more complex, more fragile, and less readable.
It's also possible to add a manual IL hook to another mod using reflection, similar to the method described here. However, changing the behavior of another mod like this is generally discouraged. Users who install a mod usually want it to behave as described, so any external changes should be minimal and well-documented. It's also even more fragile than invoking a method with reflection, since it relies on both the signature and the IL remaining relatively stable.