Making Code Mods - EverestAPI/Resources GitHub Wiki
Before you can add features to your code mod, make sure to follow the Code Mod Setup Guide.
- Reading the game's code
- Executing code when specific events occur
- Modifying the game's code
- Accessing private fields/properties/methods
Reading the code of compiled programs is made possible through the use of decompilers 🔗.
Celeste has been written in C#, and is therefore a .NET assembly. This means that you'll need a .NET decompiler to be able to read the code for Celeste.
Below is a set of commonly used .NET decompilers.
All of the listed programs are able to export the decompiled source for the game, which can then be opened and browsed with any editor.
-
ILSpy 🔗 (Windows only) & AvaloniaILSpy 🔗 (cross-platform)
- Has an addon available to modify the decompilation engine used by Visual Studio to use ILSpy's instead
-
dnSpy 🔗 (Windows only)
- An unofficial continuation of the discontinued dnSpy project
- Uses the ILSpy decompilation engine, but will not always be fully up to date
-
ILSpyCMD 🔗 (cross-platform)
- A command-line tool for decompiling assemblies
Note
Compilation and decompilation are lossy processes.
The code from a decompiler will almost always be different from the original source code, even if they perform the same operations.
Some of the most notable differences include:
- complete lack of comments (except for xmldocs)
- compiler generated types and code
- generic local variable names
- slightly different code layout
Because of the compiler generated types, often times the decompiled code is not able to be compiled back into an assembly without modifications.
The Celeste code is located in the Celeste.dll
assembly inside the game files. If you need to look at vanilla, it can be found in orig/Celeste.exe
.
Code mods are also .NET assemblies written in C#. You can decompile those in the same manner as you would Celeste.
Caution
Uploading or redistributing the decompiled Celeste code anywhere is thoroughly illegal.
You can use Everest events to execute some actions when an event such as a new level starting, etc. happens.
Everest Events Reference
For example, to call the onPlayerSpawn
method when the player spawns, use this:
Everest.Events.Player.OnSpawn += onPlayerSpawn;
If you want to modify the game's behaviour, you can check the game's code with ILSpy 🔗 or dnSpy 🔗. ILSpy gives better decompiled code, and dnSpy provides a debugger.
Once you find the method you want to modify, you can use On.Celeste
or IL.Celeste
to do this. Each of these namespaces allows you to create a type of hook - On hooks or IL hooks, respectively. (However, you should not import these namespaces in your file, as they will conflict with the main Celeste
namespace, which contains the actual Celeste types.) Hooks allow you to alter, or even replace, the behaviour of existing Celeste code.
Note: On.Celeste
and IL.Celeste
come from MMHOOK_Celeste.dll
, which is auto-generated by MonoMod HookGen 🔗 when Everest is installed. If you are on Everest Core (version >= 1.4465) you can use it right away, but if you are targeting earlier versions you need to install Everest on the OpenGL / FNA version of the game to auto-generate a working .dll, otherwise you'll need the Windows-only and obsolete XNA Framework to even compile your mod.
If you want to hook a method not provided by MMHOOK_Celeste.dll
, you can manually construct a hook. See IL.* hooks for a description of the process for IL hooks, or ExampleMod 🔗 for commented examples of both kinds of manually constructed hooks.
Those hooks allow to "replace" a method in vanilla with your own method. You can call the original method when/if you want, by using the orig
parameter passed to the hook.
For example, Extended Variants 🔗 use the following to make the game think wall-jumping is always impossible:
public void Load() {
On.Celeste.Player.WallJumpCheck += modPlayerWallJumpCheck;
}
public void Unload() {
On.Celeste.Player.WallJumpCheck -= modPlayerWallJumpCheck;
}
private static bool modPlayerWallJumpCheck(On.Celeste.Player.orig_WallJumpCheck orig, Player self, int dir) {
if (Settings.DisableWallJumping) {
// instead of running the vanilla method, return false all the time.
return false;
}
// call the vanilla method by calling the "orig" method.
return orig(self, dir);
}
When Settings.DisableWallJumping
is true, the vanilla code for Player.WallJumpCheck()
won't run, and the method will instead always return false
. Otherwise, the method will behave like vanilla.
It is commonly agreed in the Celeste modding community that On hooks should be declared as static, even if MonoMod 🔗 supports declaring as instance methods. This is because hooks are thought as some "global" modification to a method, regardless of how many or which instances exist of the class that contains the method, consequently, from a code design point of view, static makes the most sense. There's also a tiny speed improvement with this since the object that hooks a method does not need to be stored and then retrieved on each call of the hooked method.
Those hooks allow modifying the contents of a method. Those are useful when you want to inject or modify code at a specific point in a big method, and don't want to copy-paste the entirety of it in your mod.
When you add an IL hook to a method, the hook is immediately called with an ILContext
object. For example:
IL.Celeste.Player.DashBegin += modDashLength;
private void modDashLength(ILContext il) { ... }
This object allows you to modify the IL code for the method directly, and the way you want. CIL stands for Common Intermediate Language 🔗, and is a lower-level language. For instance, this code:
if (SaveData.Instance.Assists.SuperDashing) {
dashAttackTimer += 0.15f;
}
translates to:
IL_009f: ldsfld class Celeste.SaveData Celeste.SaveData::Instance <= load SaveData.Instance
IL_00a4: ldflda valuetype Celeste.Assists Celeste.SaveData::Assists <= load the Assists field in it
IL_00a9: ldfld bool Celeste.Assists::SuperDashing <= load the SuperDashing field in it
IL_00ae: brfalse.s IL_00c2 <= if this is false, jump over the contents of the if
IL_00b0: ldarg.0 <= load "this"
IL_00b1: ldarg.0 <= load "this" again
IL_00b2: ldfld float32 Celeste.Player::dashAttackTimer <= load the dashAttackTimer in this
IL_00b7: ldc.r4 0.15 <= load 0.15 to the stack
IL_00bc: add <= this adds the 2 latest loaded things, so dashAttackTimer + 0.15
IL_00bd: stfld float32 Celeste.Player::dashAttackTimer <= save the result to dashAttackTimer
IL_00c2: [...]
List of all existing instructions 🔗
A reference for what Operand type corresponds to each OpCode can be downloaded here 🔗,
as described in this stackoverflow post 🔗
In ILSpy and dnSpy, you can view the IL code by using this combo box on the top-left:
In dnSpy, you can also right-click a line of code to view its IL equivalent.
IL hooks allow you to add, remove or modify those IL instructions. For example:
private void modDashLength(ILContext il) {
ILCursor cursor = new ILCursor(il);
// jump where 0.3 or 0.15f are loaded (those are dash times)
while (cursor.TryGotoNext(MoveType.After, instr => instr.MatchLdcR4(0.3f) || instr.MatchLdcR4(0.15f))) {
Logger.Log("ExtendedVariantMode/DashLength", $"Applying dash length to constant at {cursor.Index} in CIL code for {cursor.Method.FullName}");
cursor.EmitDelegate<Func<float>>(determineDashLengthFactor);
cursor.Emit(OpCodes.Mul);
}
}
private static float determineDashLengthFactor() {
return Settings.DashLength / 10f;
}
This code looks up for every ldc.r4 0.3
or ldc.r4 0.15
in the code (that is, each time 0.3f and 0.15f are used), and multiply them with the value returned by determineDashLengthFactor()
.
Here is a what the IL code looks like before patching:
dashAttackTimer = 0.3f;
=>
ldarg.0
ldc.r4 0.3
stfld float32 Celeste.Player::dashAttackTimer
and here is a simplified view of what the code looks like after patching:
ldarg.0
ldc.r4 0.3
call float32 determineDashLengthFactor()
mul <= multiplies 0.3 and the result from determineDashLengthFactor()
stfld float32 Celeste.Player::dashAttackTimer
=>
dashAttackTimer = 0.3f * determineDashLengthFactor();
The dash attack timer (determining dash length) is now multiplied with an arbitrary factor, pulled from mod settings.
Extended Variants 🔗 rely a lot on IL hooks, to slightly alter game mechanics (like gravity and maximum fall speed, for example), so it has a lot of examples of these.
Please note that IL code is slightly different between the XNA and FNA versions, at least on Steam. Testing IL hooks against both versions is highly recommended.
Coroutines are methods returning IEnumerator
and containing yield return xxx
in them. Their name usually ends with "Routine".
- When
yield return [number]
is called, the coroutine pauses for this amount of time (in seconds). - When
yield return null
is called, the coroutine pauses for one frame. - When
yield return [IEnumerable instance]
is called, the coroutine will start to execute the passedIEnumerable
until it finishes, then resume after this statement.
Hooking them behaves particularly:
When hooking Coroutines, it is required to wrap any yield return orig()
in a SwapImmediately
object as shown here:
private static IEnumerator onFileSelectLeave(On.Celeste.OuiFileSelect.orig_Leave orig, OuiFileSelect self, Oui next) {
yield return new SwapImmediately(orig(self, next));
Logger.Log("TestMod", "I left file select!");
}
This is due to the one frame delay that is present when switching between IEnumerators in a Coroutine.
Important
To be able to use yield return new SwapImmediately(orig(self))
without issues, you need to depend on Everest 2781 or later in your everest.yaml.
Warning
This one-line method might not work in all cases, especially if the vanilla routine is a "state machine" routine that changes the state (StateMachine.State = xx
). In this case, any code you insert after the coroutine ends isn't run. To fix that, you can use this instead:
IEnumerator origEnum = orig(self);
while (origEnum.MoveNext()) yield return origEnum.Current;
The actual code of the coroutine is not in the method itself. For example, the IL code for Player.DashCoroutine()
is:
IL_0000: ldc.i4.0
IL_0001: newobj instance void Celeste.Player/'<DashCoroutine>d__423'::.ctor(int32)
IL_0006: dup
IL_0007: ldarg.0
IL_0008: stfld class Celeste.Player Celeste.Player/'<DashCoroutine>d__423'::'<>4__this'
IL_000d: ret
⬆️ This is not the actual code for the method, this only instantiates a Celeste.Player/'<DashCoroutine>d__423'
object and returns it. This is what IL.Celeste.Player.DashCoroutine += ...
will hook, so using that will lead to unexpected results.
The code you see in the C# view in ILSpy is actually located in Celeste.Player/'<DashCoroutine>d__423'::MoveNext()
, so if you want to IL hook it, this is the method you want to hook.
You can do so by building an IL hook manually:
ILHook dashCoroutineHook = new ILHook(
typeof(Player).GetMethod("DashCoroutine", BindingFlags.NonPublic | BindingFlags.Instance).GetStateMachineTarget(),
modDashSpeed);
GetStateMachineTarget()
is what allows to turn Celeste.Player::DashCoroutine()
into Celeste.Player/'<DashCoroutine>d__423'::MoveNext()
.
To undo this IL hook, you can do:
dashCoroutineHook.Dispose();
Note that building an IL hook manually is also useful to hook orig_* methods and other methods that are not made available through IL.Celeste.*
.
In order to access a private field/property/method in a class, you can use the CelesteMod.Publicizer package 🔗. Simply update your reference to the Celeste.dll
inside your .csproj
and rebuild the project.
Important
Your IDE might complain about not finding the Celeste types. This can usually be resolved by just restarting it / invalidating it's cache.
Then you can simply access everything as if it were public:
Sprite vanillaSprite = someStrawberrySeedObject.sprite;
someStrawberrySeedObject.sprite = modSprite;
You might realize that some parts are still not public. Those are part of Everest and are intentionally left alone, to avoid mods depending on Everest-internal behaviour.
Alternativly, you can use DynamicData 🔗. For that, create or retrieve the cached DynamicData object, passing it the object you want the access the fields of:
DynamicData strawberrySeedData = new DynamicData(someStrawberrySeedObject);
// For performance, it is recommended to use DynamicData.For() to create the dynamicData once, and reuse it each time it is required.
DynamicData strawberrySeedData = DynamicData.For(someStrawberrySeedObject);
if you just want to access a static field/property/method
DynamicData inputData = new DynamicData(typeof(Input));
After doing that, you can access and set the fields/properties you want on someStrawberrySeedObject
by doing this:
Sprite vanillaSprite = strawberrySeedData.Get<Sprite>("sprite"); // gets someStrawberrySeedObject.sprite
strawberrySeedData.Set("sprite", modSprite); // changes someStrawberrySeedObject.sprite
You can also invoke private methods:
strawberrySeedData.Invoke("OnGainLeader");
strawberrySeedData.Invoke("OnPlayer", player);
DynamicData can also be used to "attach" data to any object. This is done by simply writing to a field that doesn't actually exist in the object strawberrySeedData.Set("nonExistentField", 42)
. You can then get this field back with strawberrySeedData.Get<int>("nonExistentField")
. This can be used, for example, to save some data in a hook on an entity, and get it back later when the hook is executed again / in another hook.
Note
DynamicData is slower performance-wise and more annoying to use, so you'll probably want to use the publicizer in most cases.