Skip to content

Rewrite Operations

Everything below is pick one. Do not chain several of them onto the same old match. After each Apply, Match again if you want to keep editing.

Value

Transform: read the old value, return a new one

damage.Transform(Hooks.ClampDamage)
      .Apply(VerifyOptions.Full);

// static int ClampDamage(int original)

The original value arrives as the callback's first parameter, and the return value goes back to the game's own logic. The return type has to fit where the original value went.

Observe: look, do not change

damage.Observe(Hooks.LogDamage)
      .Apply(VerifyOptions.Full);

// static void LogDamage(int original)

For logging, statistics, and raising mod events. The original value still reaches the game. If the callback returns something, it is discarded by default.

Replace: skip the original computation

damage.Replace(Hooks.FixedDamage)
      .Apply(VerifyOptions.Full);

// static int FixedDamage()

The original expression is not executed, and the callback does not automatically receive the old value. Every argument it needs must come from args.

Before / After: add behaviour around it

damage.Before(Hooks.OnDamageCalculationStarted)
      .Apply(VerifyOptions.Full);

damage.After(Hooks.OnDamageCalculated)
      .Apply(VerifyOptions.Full);

After only means "call once the computation is finished". It does not pass the original value to the callback — use Observe for that.

Effect

var effect = method.Match(soundPattern).Single();

effect.Before(Hooks.BeforeSound)
      .Apply(VerifyOptions.Full);

effect.After(Hooks.AfterSound)
      .Apply(VerifyOptions.Full);

effect.Replace(Hooks.PlayCustomSound)
      .Apply(VerifyOptions.Full);

effect.Remove()
      .Apply(VerifyOptions.Full);

Four independent choices: insert before, insert after, replace wholesale, delete.

Condition

var condition = method.Match(gatePattern).Single();
condition.Transform(Hooks.ChangeGate)
         .Apply(VerifyOptions.Full);

// static bool ChangeGate(bool original)

Change the final true/false result. The callback must return bool.

condition.Observe(Hooks.LogGate)
         .Apply(VerifyOptions.Full);

Record the outcome without changing which branch is taken.

condition.Replace(Hooks.CustomGate)
         .Apply(VerifyOptions.Full);

Replace the test entirely.

Conditions also support Before, but not After: a condition can finish at different places, so there is no single "line after it".

Check first that a rewrite is possible at all:

if (!condition.CanRewrite)
    Console.WriteLine(condition.RewriteFailureReason);

What a callback can be

Source Good for
A strongly typed delegate such as Func<int, int> Runtime mods
CilMethodSpec Describing a static callback by signature
A Cecil MethodReference Offline patchers that already hold a method reference

A static delegate is lowered to a direct call to that static method. Instance delegates, closures, and multicast delegates are runtime-only.

Pass the method group directly: damage.Transform(Hooks.ClampDamage). Only on C# 9 and below (a net48 project without LangVersion defaults to 7.3) does a callback on a capture need an explicit delegate type, e.g. match.Arg("x").Transform((Func<int, int>)Hooks.ClampDamage) — method groups had no natural type before C# 10. A root ValueMatch<T> never needs it, because T is already known.

Extra callback arguments

Transform and Observe put the original value in the first parameter automatically. The rest come from args => ..., in order:

damage.Transform(
          Hooks.AdjustDamage,
          args => args
              .Arg(0)
              .Constant(999))
      .Apply(VerifyOptions.Full);

// static int AdjustDamage(int original, int firstMethodArg, int limit)
Call What the callback receives
args.This() The current object
args.Arg(0) The target method's first explicit parameter
args.Arg(argumentCapture) A captured parameter
args.Local(localCapture) The current value of a captured local
args.Capture(valueCapture) The value of a captured expression
args.Constant(100) A constant; overloads exist for every primitive
args.ConstantI4(value, nominalType) An integer constant passed under a specific nominal type
args.Null(type) A null reference

MonoWeaver checks argument count, order, and types. If a captured value is not yet available at the callback site, it refuses before touching the method.

Where the callback result goes

See The Rewrite Plan. The short version:

  • Transform and value Replace return values go straight back to the game; nothing to configure;
  • a non-void Observe result is discarded by default;
  • a non-void Before / After result is left on the stack by default, so a normal hook should call Discard() or Store(...) explicitly.
damage.Observe(Hooks.RecordAndReturnId)
      .StoreLocal(logIdLocal)
      .Apply(VerifyOptions.Full);