Cross Mod Functionality - EverestAPI/Resources 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.

Table of contents

Dependency Management

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.

Code Safety

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.

Techniques

Below are several different ways to implement cross-mod functionality, roughly in order from most to least recommended.

Direct Addition

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

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):

Assembly Reference

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.

⚠️ If your code references a dependency that isn't loaded, the game will hard crash. You can avoid this by making it a required dependency, or adding a check for optional dependencies to see if they are loaded before you reference them.

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

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.

Hooks

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.

⚠️ **GitHub.com Fallback** ⚠️