-
Notifications
You must be signed in to change notification settings - Fork 8
Cross Mod Functionality
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:
// Interop exports provided by Communal Helper
[ModExportName("CommunalHelper.DashStates")]
public static class DashStates {
public static int GetDreamTunnelDashState() => DreamTunnelDash.StDreamTunnelDash;
}
// Create this class in your project
[ModImportName("CommunalHelper.DashStates")]
public static class CommunalHelperImports {
public static Func<int> GetDreamTunnelDashState;
}
// Add this using statement to your module's class file
using MonoMod.Utils;
// Add this call to your module's Load() method
typeof(CommunalHelperImports).ModInterop();
// Using the import
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.
Note
To use a ModInterop API, you should add an optional dependency (hard dependency is fine) 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):
- Achievement Helper 🔗
- BGswitch 🔗
- Cavern Helper 🔗
- Communal Helper 🔗
- Collab Utils 2 - Lobby utility methods 🔗 and custom heart sprite bank access 🔗 (see at the bottom of both classes)
- Frost Helper 🔗
- Gravity Helper 🔗
- Head 2 Head 🔗
- Speedrun Tool 🔗
- Viv Helper 🔗
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.
Warning
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)
};
communalHelperLoaded = Everest.Loader.DependencyLoaded(communalHelper);
// 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.
Home
Contributing
FAQ
Useful Links
Your First Custom Map
Your First Texture Pack
Mod Setup
Custom Maps
Texture Packs
Uploading Mods
Generated Dialog Keys
Reference Code Mod🔗
Vanilla Audio IDs
Vanilla Decal Registry Reference
Character Portraits
Mod Structure
Debug Mode
Debug Map
Command Line Arguments
Environment Variables
Install Issues
Common Crashes
Latency Guide
everest.yaml Setup
Mapping FAQ
Map Metadata
Vanilla Metadata Reference
Adding Custom Dialogue
Overworld Customisation
Entity & Trigger Documentation
Custom Entity List🔗
Camera
Ahorn Scripts
Custom Tilesets
Tileset Format Reference
Stylegrounds
Reskinning Entities
Skinmods
Decal Registry
Chapter Complete Screen
Custom Portraits
Adding Custom Audio
Advanced Custom Audio
Code Mod Setup
Making Code Mods
Settings, SaveData and Session
Everest Events
Understanding Input
Logging
Cross-Mod Functionality
Recommended Practices
Core Migration Guide
Lönn Integration🔗
Custom Events
Adding Sprites
Adding Preexisting Audio