Making Code Mods - coloursofnoise/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 for the vanilla game is made possible through the use of Decompilers. There are a number of decompilers and ways to decompile available, with different benefits for each.
https://github.com/dnSpyEx/dnSpy
- An unofficial continuation of the discontinued dnSpy project
- Allows for directly modifying the assembly (not recommended for Celeste modding except for testing)
- Includes a debugger that can be attached to the Celeste process
- Uses the ILSpy decompilation engine, but will not always be fully up to date.
https://github.com/icsharpcode/ILSpy (Windows only)
https://github.com/icsharpcode/AvaloniaILSpy (cross-platform)
- Decompilation engine used by Visual Studio and Visual Studio Code C#
- Includes a commandline tool for decompiling assemblies: ILSpyCMD
All of the above programs (dnSpy, ILSpy, ILSpyCMD) can be used to export the decompiled source for the game, which can then be opened and browsed with any editor. This code is unlikely to compile without modifications, as the decompilation process is not perfect and some compiler-generated types are included directly.
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;
Note: On.Celeste
and IL.Celeste
come from MMHOOK_Celeste.dll
, which is auto-generated by MonoMod HookGen:link: when Everest is installed. 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 modify the game's behaviour, you can check the game's code with ILSpy:link: or dnSpy:link:. ILSpy gives better decompiled code, and dnSpy provides a debugger.
Once you found the method you want to modify, you can use On.Celeste
or IL.Celeste
to do this.
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:link: 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 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.
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:link:, 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:link:
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:link: 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.
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.
âšī¸ 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.
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 DynamicData:link:. 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/mehtod
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
Invoke private mehtods
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.