Skip to content

Basic ILHook Examples

You should have a basic understanding of CIL and how ILHooks work from the previous article. In this article, we will be learning from basic ILHook examples. These examples are implemented in MonoDetour’s Tests to ensure they are correct.

The goal is to show multiple unique ILHooks to both teach ILWeaver and to strengthen your understanding of how ILHooks work in general.

In this example, we are going to fix the mistake in this method:

public static bool IsAboveZero(float num)
{
if (num >= 0)
return true;
else
return false;
}

the method should return true if ‘num’ is above 0, but mistakenly, it also returns true if ‘num’ is equal to 0!

The target method’s instructions look like this:

IL_0000: ldarg.0 // load arg (float)num
IL_0001: ldc.r4 0.0 // load (float)0
IL_0006: blt.un.s IL_000a // branch to labelRetFalse if
// (float)num is less than (float)0
IL_0008: ldc.i4.1 // load (int)1 (equivalent to 'true')
IL_0009: ret // and return
IL_000a: ldc.i4.0 // load (int)0 (equivalent to 'false')
IL_000b: ret // and return

In the instructions, ‘blt.un’ is used, whose logic is equivalent to the less than ’<’ operator in C#.

Here’s the logic rewritten in C# to closely follow the logic and control flow of the instructions:

public static bool IsAboveZero(float num)
{
if (num < 0)
goto labelReturnFalse;
return true;
labelReturnFalse:
return false;
}

In the above snippet we can see that if ‘num’ is less than 0, we return false. To make the logic correct, the condition needs to be less than or equal.

The OpCode for this is ‘ble.un’, which is described as following:

Transfers control to a target instruction if the first value is less than or equal to the second value, when comparing unsigned integer values or unordered float values.

So our goal is to change the ‘blt.un’ OpCode with ‘ble.un’, and then the behavior of the IsAboveZero method is fixed.

static void ILHook_IsAboveZero(ILManipulationInfo info)
{
ILWeaver w = new(info);
// When matching the blt.un OpCode, we want to store the
// branch label here for our replacement instruction.
ILLabel? labelReturnFalse = null!;
// We perform a sufficiently unique match on the instructions to
// find the exact location we are looking for, and set the ILWeaver's
// Current property to the instruction with the blt.un OpCode.
ILWeaverResult result = w.MatchRelaxed(
x => x.MatchLdarg(0),
x => x.MatchLdcR4(0),
x => x.MatchBltUn(out labelReturnFalse) && w.SetCurrentTo(x)
);
// If the match failed, throw.
result.ThrowIfFailure();
// We replace the instruction that has blt.un with our instruction.
w.ReplaceCurrent(w.Create(OpCodes.Ble_Un, labelReturnFalse));
}
The ILHook also needs to be applied

This is the common boilerplate for MonoDetourManager.InvokeHookInitializers usage:

[MonoDetourTargets(typeof(SomeApp))]
static class SomeAppHooks
{
[MonoDetourHookInitialize]
static void Init()
{
Md.SomeNamespace.SomeApp.IsAboveZero.ILHook(ILHook_IsAboveZero);
}
static void ILHook_IsAboveZero(ILManipulationInfo info)
{
// ... code here ...
}
}

And then where you initialize your mod, you call this method once on your assembly:

MonoDetourManager.InvokeHookInitializers(Assembly.GetExecutingAssembly());

This information won’t be repeated for the rest of the examples.

In this example, we are going to add new logic inside the (fictional) Gun.OnUse method:

class Gun(int magazineSize) : Item
{
internal int _ammo = magazineSize;
public override void OnUse(Entity user)
{
if (_ammo <= 0)
return;
_ammo--;
if (!user.TryGetTarget(out Entity? target))
{
return;
}
target.TakeDamage(4);
}
}

We’ll add logic to make the gun deal double damage if the last ammo was used. An easy way to add such logic is by inserting our own CalculateDamage method:

target.TakeDamage(CalculateDamage(this, 4));

Let’s take a look at the instructions to see how this could be done. What we are looking for is the Entity::TakeDamage(int32) call near the end.

IL_0000: ldarg.0
IL_0001: ldfld int32 Gun::_ammo
IL_0006: ldc.i4.0
IL_0007: bgt.s IL_000a
IL_0009: ret
IL_000a: ldarg.0
IL_000b: ldarg.0
IL_000c: ldfld int32 Gun::_ammo
IL_0011: ldc.i4.1
IL_0012: sub
IL_0013: stfld int32 Gun::_ammo
IL_0018: ldarg.1
IL_0019: ldloca.s 0
IL_001b: callvirt instance bool Entity::TryGetTarget(class Entity&)
IL_0020: brtrue.s IL_0023
IL_0022: ret
IL_0023: ldloc.0
IL_0024: ldc.i4.4
IL_0025: callvirt instance void Entity::TakeDamage(int32) // <-- We want here
IL_002a: ret

Let’s get to writing the ILHook.

// Somewhere, initialize the ILHook
Md.Gun.OnUse.ILHook(ILHook_OnUse);
static void ILHook_OnUse(ILManipulationInfo info)
{
ILWeaver w = new(info);
// As always, we perform a match and point Current to what we are looking for.
ILWeaverResult result = w.MatchRelaxed(
x => x.MatchLdloc(out _),
x => x.MatchLdcI4(out _),
x => x.MatchCallvirt<Entity>(nameof(Entity.TakeDamage)) && w.SetCurrentTo(x)
);
result.ThrowIfFailure();
// We insert our instructions just before the Entity.TakeDamage method call.
w.InsertBeforeCurrent(
// Load arg 0, which is always 'this' for instance (non-static) methods
w.Create(OpCodes.Ldarg_0),
// Add a call to our method which consumes the base damage and 'this' from the stack.
w.CreateDelegateCall(CalculateDamage)
);
// CalculateDamage returns int, so stack becomes [Entity, int] for
// the Entity.TakeDamage method, which is correct.
}
static int CalculateDamage(int baseDamage, Gun self)
{
if (self._ammo == 0)
return baseDamage * 2;
else
return baseDamage;
}