Skip to content
Concord

Common Tasks

The patches you'll use most often. Assumes you're comfortable reading C#.

Every patch declaration is marked with [Patch]. Most extend the target type. Use [Patch(typeof(TargetType))] or a string target when the declaration cannot inherit from it.

Patch forms

The form of [Inject] decides what Concord matches. At.* decides where the injection runs within that form.

Patch form Attribute form Fluent form
Target method [Inject(At.Head, nameof(Method))] .Head(...), .Tail(...), .Return(...), or .Around(...)
Call site [Inject(nameof(Method), typeof(Owner), nameof(Owner.Call), At.Around)] .Invoke(...)
Construction [InjectNew(nameof(Method), typeof(Built), At.Around)] .NewObj(...)
Constant [Inject(nameof(Method), 5, At.Constant)] No high-level fluent form
Constructor body [Inject(At.Head)] Patcher.ForConstructor(...)

The examples use attributes unless the fluent form makes target selection clearer. Reverse patches and injected members do not use an At.* position.

Target-method injections

[Inject(At.*, nameof(Method))] patches the body of the named target method.

At.Head

Run code before the target method

Use At.Head:

[Patch]
abstract class DoorPatch : Door
{
    [Inject(At.Head, nameof(Open))]
    void BeforeOpen()
    {
        Logger.Info("A door is opening.");
    }
}

Good for logging, changing arguments or target members, and gating the method before its body runs. At runtime:

public void Open()
{
    Logger.Info("A door is opening.");
    IsOpen = true;
}

Change a target method parameter

A Head injection can assign to a parameter with the same name and type as the target parameter:

[Patch]
abstract class DamagePatch : GameActor
{
    [Inject(At.Head, nameof(TakeDamage))]
    void ClampDamage(int amount)
    {
        amount = Math.Max(0, amount);
    }
}

Concord maps amount to the target argument. The assignment changes the value read by the original body. At runtime:

public void TakeDamage(int amount)
{
    amount = Math.Max(0, amount);
    hitPoints -= amount;
}

Stop the target method

Return Control from a head injection:

[Patch]
abstract class DoorPatch : Door
{
    [Inject(At.Head, nameof(Open))]
    Control BeforeOpen()
    {
        return IsLocked ? Control.Cancel : Control.Continue;
    }
}

Control.Cancel skips the original method, and Control.Continue runs it. For a void target that's the whole story. If the method returns a value, you also need to set ReturnValue (next section).

An injection that already takes a ControlHandle can call ch.Cancel() instead. The two forms do the same thing, and a later patch can't undo either:

[Inject(At.Head, nameof(Open))]
void BeforeOpen(ControlHandle ch)
{
    if (IsLocked)
    {
        ch.Cancel();
    }
}

At runtime:

public void Open()
{
    if (IsLocked)
        return;

    IsOpen = true;
}

Cancel and return a value

For a non-void target, set ReturnValue before or when you cancel:

[Patch]
abstract class PricePatch : ShopItem
{
    [Inject(At.Head, nameof(GetPrice))]
    Control BeforeGetPrice(ControlHandle<int> ch)
    {
        if (IsFree)
        {
            ch.ReturnValue = 0;
            return Control.Cancel;
        }

        return Control.Continue;
    }
}

If you cancel a non-void method without setting a return value, Concord reports CONC012. At runtime:

public int GetPrice()
{
    if (IsFree)
        return 0;

    return BasePrice;
}

At.Tail

Run code at the target method's last return

Use At.Tail for code that should run when execution reaches the last return in the method:

[Patch]
abstract class DoorPatch : Door
{
    [Inject(At.Tail, nameof(Open))]
    void AfterOpen()
    {
        Logger.Info("The door opened.");
    }
}

An earlier return skips the Tail injection. At runtime:

public void Open()
{
    if (IsLocked)
        return;

    IsOpen = true;
    Logger.Info("The door opened.");
}

Replace a return value

Use ControlHandle<T>, where T is the method's return type:

[Patch]
abstract class PricePatch : ShopItem
{
    [Inject(At.Tail, nameof(GetPrice))]
    void AfterGetPrice(ControlHandle<int> ch)
    {
        ch.ReturnValue = 1;
    }
}

At.Tail runs before the method's last return and swaps the result to 1. If the method has several return statements and you want to rewrite the value at each one (including early returns), use At.Return instead. At runtime:

public int GetPrice()
{
    int result = BasePrice;
    result = 1;
    return result;
}

At.Return

Run code at every return

Use At.Return when the target has several exits and each one needs the injection. ControlHandle<T> contains the value from the current return site:

[Patch]
abstract class PricePatch : ShopItem
{
    [Inject(At.Return, nameof(GetPrice))]
    void ClampPrice(ControlHandle<int> ch)
    {
        ch.ReturnValue = Math.Max(0, ch.ReturnValue);
    }
}

by: 0 (the default) targets every return. Pass by: 2 to target only the second return in the method.

At.Around

Wrap the whole method

At.Around with only a method name wraps the entire target. Your injection declares an Operation family parameter and calls original.Invoke(...) to run the target body. Everything before the call runs first, everything after runs last, and you can read or replace the result:

[Patch]
abstract class LoadPatch : SaveSystem
{
    [Inject(At.Around, nameof(Load))]
    object WrapLoad(string path, Operation<string, object> original)
    {
        Logger.Info($"Loading {path}");
        object result = original.Invoke(path);
        Logger.Info("Loaded.");
        return result;
    }
}

original matches the target method's parameters and return type, following the same Operation/VoidOperation table as call-site Invoke with At.Around. original.Invoke(...) can pass changed arguments, and calling it from more than one call site runs the target body more than once. Leaving out the call skips the target body and uses the injection's return value instead.

At runtime, the wrapper behaves like this:

public object Load(string path)
{
    Logger.Info($"Loading {path}");
    object result = /* original Load body */;
    Logger.Info("Loaded.");
    return result;
}

Only one whole-method Around can target a method; a second fails with CONC051. Head, Return, and Tail injections can compose alongside it; Concord rejects call-site Invoke, Argument, and Constant injections on that target (CONC115). For the full treatment, including try/finally and multiple returns, see How patches work.

State slots

Share state across injections

ControlHandle and ControlHandle<T> both carry a state slot. Write it from one injection, then read it from another in the same patch declaration:

[Patch]
abstract class PricePatch : ShopItem
{
    [Inject(At.Head, nameof(GetPrice))]
    void BeforeGetPrice(ControlHandle<int> ch)
    {
        ch.SetState(IsFree ? 0 : ShippingCost);
    }

    [Inject(At.Tail, nameof(GetPrice))]
    void AfterGetPrice(ControlHandle<int> ch)
    {
        ch.ReturnValue += ch.GetState<int>();
    }
}

At runtime:

public int GetPrice()
{
    int state = IsFree ? 0 : ShippingCost;
    int result = BasePrice;
    result += state;
    return result;
}

The slot belongs to one patch declaration and one target call. Concord keys it on the injection method's declaring type, so every injection in PricePatch shares one slot. A second mod that patches GetPrice gets its own slot and cannot read yours.

Every injection in the declaration must agree on the slot type. Two types in one declaration fail with CONC127. A GetState<T>() call that nothing wrote returns default(T).

For the scoping and lifetime rules, including what happens on an async target, see State slots.

Call-site injections

The invoke form matches a method or property call inside the target method. With At.Head or At.Tail, it can also match a field read. Its final At.* argument controls what happens at that access.

At.Head

Run code before a call inside the target

Use the invoke form with At.Head to run code before a matched call:

[Patch]
abstract class PricePatch : ShopItem
{
    [Inject(nameof(GetFinalPrice), typeof(PriceRules), nameof(PriceRules.ApplyMarkup), At.Head)]
    void BeforeApplyMarkup()
    {
        Logger.Info("Applying markup.");
    }
}

At runtime:

public int GetFinalPrice(int basePrice)
{
    Logger.Info("Applying markup.");
    int markedUp = PriceRules.ApplyMarkup(basePrice);
    return markedUp + ShippingCost;
}

At.Tail

Run code after a call inside the target

Use the invoke form with At.Tail to run code after a matched call:

[Patch]
abstract class PricePatch : ShopItem
{
    [Inject(nameof(GetFinalPrice), typeof(PriceRules), nameof(PriceRules.ApplyMarkup), At.Tail)]
    void AfterApplyMarkup()
    {
        Logger.Info("Markup applied.");
    }
}

At runtime:

public int GetFinalPrice(int basePrice)
{
    int markedUp = PriceRules.ApplyMarkup(basePrice);
    Logger.Info("Markup applied.");
    return markedUp + ShippingCost;
}

At.Tail runs only after ApplyMarkup returns. It does not expose the returned value. Use At.Around when the injection needs to read or replace that value.

Run code after a field read

Head and Tail invoke injections can target static or instance field reads:

public static class PriceDefaults
{
    public static readonly int BasePrice = 10;
}

public class ShopItem
{
    public int GetFinalPrice(int quantity)
    {
        return PriceDefaults.BasePrice * quantity;
    }
}

[Patch]
abstract class PricePatch : ShopItem
{
    [Inject(nameof(GetFinalPrice), typeof(PriceDefaults), nameof(PriceDefaults.BasePrice), At.Tail)]
    void AfterBasePriceRead()
    {
        Logger.Info("Base price read.");
    }
}

At.Head runs immediately before the field read. At.Tail runs immediately after it. Field writes, At.Around, and At.Argument are not field targets. Leave invokeParameterTypes unset when targeting a field.

At.Around

Wrap a call inside the target

At.Around can wrap a whole target method or one call inside it. The invoke form here wraps one matched call. Your injection gets an Operation handle matching that call: run it where you want, change its arguments, or skip it.

[Patch]
abstract class PricePatch : ShopItem
{
    [Inject(nameof(GetFinalPrice), typeof(PriceRules), nameof(PriceRules.ApplyMarkup), At.Around)]
    int AroundApplyMarkup(int basePrice, Operation<int, int> original)
    {
        Logger.Info("Applying markup.");
        return original.Invoke(basePrice);
    }
}

The invoke form takes the call site's declaring type and method name, plus a shift. At.Around wraps the original call: your injection method gets an Operation handle matching the call's arguments and return type, and decides whether to call original.Invoke(...). If it does not, the injection replaces the call. PriceRules.ApplyMarkup takes one int and returns int, so the handle here is Operation<int, int>; see How patches work for the full family. At.Head runs code before the call. To run code after it, use At.Around and place that code after original.Invoke(...), as shown next. An optional by ordinal picks one matching call, counting from 1; leave it at 0 to match every call site.

Around invoke supports up to eight arguments for a call that returns a value, and up to eight for a void call. Calls on value-type receivers are not supported. Concord reports CONC039 when a call does not fit those limits.

public int GetFinalPrice(int basePrice)
{
    Logger.Info("Applying markup.");
    int markedUp = PriceRules.ApplyMarkup(basePrice);
    return markedUp + ShippingCost;
}

After a call inside the target

Since At.Around wraps the call, "after" is code after original.Invoke(...):

[Inject(nameof(GetFinalPrice), typeof(PriceRules), nameof(PriceRules.ApplyMarkup), At.Around)]
int AroundApplyMarkup(int basePrice, Operation<int, int> original)
{
    int markedUp = original.Invoke(basePrice);
    Logger.Info($"Marked up price: {markedUp}");
    return markedUp;
}

At runtime:

public int GetFinalPrice(int basePrice)
{
    int markedUp = PriceRules.ApplyMarkup(basePrice);
    Logger.Info($"Marked up price: {markedUp}");
    return markedUp + ShippingCost;
}

Change a call's argument

Pass different arguments to original.Invoke(...):

[Inject(nameof(GetFinalPrice), typeof(PriceRules), nameof(PriceRules.ApplyMarkup), At.Around)]
int WrapApplyMarkup(int basePrice, Operation<int, int> original)
{
    int discountedBase = basePrice - 5;
    return original.Invoke(discountedBase);
}

At runtime:

public int GetFinalPrice(int basePrice)
{
    int discountedBase = basePrice - 5;
    int markedUp = PriceRules.ApplyMarkup(discountedBase);
    return markedUp + ShippingCost;
}

Change several call arguments

An Around invoke can replace several arguments in one call. Pass each new value to original.Invoke(...):

[Patch]
abstract class PricePatch : ShopItem
{
    [Inject(nameof(GetSalePrice), typeof(PriceRules), nameof(PriceRules.Calculate), At.Around)]
    int AroundCalculate(
        int basePrice,
        int discount,
        bool taxable,
        Operation<int, int, bool, int> original)
    {
        int safePrice = Math.Max(0, basePrice);
        int cappedDiscount = Math.Min(discount, 50);
        return original.Invoke(safePrice, cappedDiscount, false);
    }
}

original.Invoke(...) sends all three replacement values to PriceRules.Calculate(...). Use At.Argument when only one argument needs a replacement.

Replace a call entirely

Don't call original.Invoke(...). Return your own value:

[Inject(nameof(GetFinalPrice), typeof(PriceRules), nameof(PriceRules.ApplyMarkup), At.Around)]
int ReplaceApplyMarkup(int basePrice, Operation<int, int> original)
{
    if (UseFlatPrice)
    {
        return 20;
    }

    return original.Invoke(basePrice);
}

Be careful here. Skipping the original call means skipping its side effects too. At runtime:

public int GetFinalPrice(int basePrice)
{
    int markedUp = UseFlatPrice
        ? 20
        : PriceRules.ApplyMarkup(basePrice);

    return markedUp + ShippingCost;
}

Wrap a value read

The invoke position also matches a property getter, since a getter is a method call underneath. Say ShopItem.Total reads supplier.BasePrice:

public int Total()
{
    return supplier.BasePrice;
}

Target the property by name. Concord resolves it to the getter and shapes the Operation<T> handle to match:

[Patch]
abstract class PricePatch : ShopItem
{
    [Inject(nameof(Total), typeof(Supplier), nameof(Supplier.BasePrice), At.Around)]
    int ShimBase(Operation<int> read)
    {
        return read.Invoke() - 2;
    }
}

At runtime:

public int Total()
{
    int basePrice = supplier.BasePrice;
    return basePrice - 2;
}

A property with one accessor resolves from its property name, so nameof(Supplier.BasePrice) and the literal "get_BasePrice" reach the same call site in this example. If the property has both a getter and a setter, name the accessor directly ("get_BasePrice" or "set_BasePrice") unless the injection's Operation signature disambiguates it.

At.Argument

Rewrite a call argument

At.Argument rewrites one argument of a matched call without wrapping the whole call. The injection method takes and returns the argument's type:

[Patch]
abstract class PricePatch : ShopItem
{
    [Inject(nameof(GetFinalPrice), typeof(PriceRules), nameof(PriceRules.ApplyMarkup), At.Argument, arg: 1)]
    int Clamp(int original)
    {
        return original > 10 ? 10 : original;
    }
}

arg: 1 is 1-based and picks the first argument of the matched call. Leave arg at its default of 0, and Concord infers the argument by type. This works as long as exactly one parameter on the call site matches the injection method's parameter type:

[Inject(nameof(GetFinalPrice), typeof(PriceRules), nameof(PriceRules.ApplyMarkup), At.Argument)]
int RaiseMinimum(int original)
{
    return original < 10 ? 10 : original;
}

If more than one argument shares that type, inference is ambiguous and composition fails with an error naming arg: so you know to pass it explicitly.

[Capture]

Read an argument of a matched call

Mark an injection parameter with [Capture(n)] to bind it to argument n of the matched call. The ordinal counts from 1:

[Patch]
abstract class PricePatch : ShopItem
{
    [Inject(nameof(GetFinalPrice), typeof(PriceRules), nameof(PriceRules.ApplyMarkup), At.Tail)]
    void AfterApplyMarkup([Capture(1)] int basePrice)
    {
        Logger.Info($"Marked up {basePrice}.");
    }
}

At runtime:

public int GetFinalPrice(int basePrice)
{
    int captured = basePrice;
    int markedUp = PriceRules.ApplyMarkup(captured);
    Logger.Info($"Marked up {captured}.");
    return markedUp + ShippingCost;
}

[Capture] works at At.Head and At.Tail of an invoke or construction injection. At.Around already passes the call's arguments, and At.Argument passes the one it rewrites, so [Capture] at either reports CONC128. Any other position reports CONC128 too, because it matches no call site.

The parameter must declare the argument's own type, or its element type when the argument is by-ref. A mismatch reports CONC130, and so does an ordinal past the last argument. A field read supplies no arguments, so [Capture] on one also reports CONC130.

Concord reports CONC129 when it cannot tell where an argument finished pushing. A conditional expression in a call argument causes this, including ?:, ??, ?., &&, and ||. A conditional in the last argument blocks every argument of that call, not only the conditional one. Move the conditional into a local before the call, then capture the argument from there.

A capture reports what the call received, even when the callee writes back through a ref parameter. See Capture an argument for the by-ref rules.

[InjectNew]

Patch an object construction

[InjectNew] matches a newobj instruction inside the target method. It takes the same shifts as the invoke form. Say ShopItem.Checkout builds a Receipt:

public int Checkout(int orderId)
{
    Receipt receipt = new Receipt(orderId);
    return receipt.Total;
}

At.Around wraps the construction. The injection gets an Operation handle shaped from the constructor's arguments and the constructed type. Whatever the injection returns becomes the object the target body uses:

[Patch]
abstract class ReceiptPatch : ShopItem
{
    [InjectNew(nameof(Checkout), typeof(Receipt), At.Around)]
    Receipt SwapReceipt(int orderId, Operation<int, Receipt> original)
    {
        Receipt built = original.Invoke(orderId);
        return TaxEnabled ? new TaxedReceipt(orderId) : built;
    }
}

TaxedReceipt has to derive from Receipt, because the rest of the target body still expects that type. At runtime:

public int Checkout(int orderId)
{
    Receipt built = new Receipt(orderId);
    Receipt receipt = TaxEnabled ? new TaxedReceipt(orderId) : built;
    return receipt.Total;
}

At.Argument rewrites one constructor argument and leaves the construction alone:

[InjectNew(nameof(Checkout), typeof(Receipt), At.Argument, arg: 1)]
int ShiftOrderId(int original)
{
    return original + 1;
}

At runtime:

public int Checkout(int orderId)
{
    Receipt receipt = new Receipt(orderId + 1);
    return receipt.Total;
}

At.Head and At.Tail run code before and after the construction. Pass invokeParameterTypes: to pick one constructor overload. Pass by: to pick one construction when the body builds the type more than once.

A construction patch does not change what new Receipt(...) means everywhere. It changes the one newobj instruction the injection matched.

Why a struct construction may not match

[InjectNew] matches newobj, and a struct does not always produce one. The C# compiler initializes a struct local in place, so this pair of lines emits no newobj:

Coin coin = new Coin(seed);
return coin.Value;

A struct emits newobj only when the surrounding code consumes the constructor result as an expression. Either of these forms does that:

return new Coin(seed).Value;

// or, passing the result straight to a call:
Save(new Coin(seed));

Constructor complexity makes no difference here. The rule is unconditional for a struct local. A class always emits newobj, so reference types never hit this. When [InjectNew] finds nothing in code that plainly constructs a struct, this is the reason, and Concord reports CONC031.

[Slice]

Bound a call-site search to a range

[Slice] limits an invoke or construction search to the code between two anchors. The injection's by then counts inside that range. Say Checkout calls PriceRules.Total three times:

public int Checkout(int seed)
{
    int subtotal = PriceRules.Total(seed);
    Audit.Begin();
    int discounted = PriceRules.Total(subtotal);
    Audit.End();
    return PriceRules.Total(discounted);
}

Anchor the range on Audit.Begin and Audit.End to reach the middle call:

[Patch]
abstract class PricePatch : ShopItem
{
    [Inject(nameof(Checkout), typeof(PriceRules), nameof(PriceRules.Total), At.Head, by: 1)]
    [Slice(typeof(Audit), nameof(Audit.Begin), 1, typeof(Audit), nameof(Audit.End), 1)]
    void BeforeAuditedTotal([Capture(1)] int seed)
    {
        Logger.Info($"Audited total for {seed}.");
    }
}

At runtime:

public int Checkout(int seed)
{
    int subtotal = PriceRules.Total(seed);
    Audit.Begin();
    Logger.Info($"Audited total for {subtotal}.");
    int discounted = PriceRules.Total(subtotal);
    Audit.End();
    return PriceRules.Total(discounted);
}

The range opens just after the opening anchor and closes just before the closing anchor. Neither anchor sits inside the range.

The two ordinals count over different regions. fromBy and toBy count anchors across the whole body. The injection's own by counts matches inside the range. That asymmetry is the point of the feature. Here by: 1 picks the first match in the range, which is the second PriceRules.Total call in the method.

Leave both fromType and fromMember null to open the range at the body head. Leave both toType and toMember null to close it at the body tail. An anchor is the pair, so naming one half without the other is an error. A fromType with no fromMember reports CONC131, and a toType with no toMember reports CONC132.

An anchor has to be a method call, a property accessor call, or a field read. A construction cannot serve as an anchor. Concord then reports CONC131 or CONC132 and says the method body does not contain the member.

Code Means
CONC131 The body has no opening anchor at that occurrence, or only one half of the opening anchor was named
CONC132 The body has no closing anchor at that occurrence, or only one half of the closing anchor was named
CONC133 The range is empty or inverted, so it closes at or before it opens
CONC134 [Slice] sits on a position that matches no call site

Two things can move a range out from under you. Another mod's transpiler can add or remove an anchor; see Rules your transpiler must follow. Concord also resolves anchors against a body that earlier injections have already spliced into. An injection body that calls an anchor member therefore shifts the anchor count.

Constant injections

At.Constant matches an inlined literal in a target method.

At.Constant

Replace a constant

At.Constant targets an inlined literal in the target body instead of a call. It supports int, long, float, double, and string literals. The injection method takes and returns the constant's type:

public class AgeGate
{
    public bool Allows(float age) => age >= 18f;

    public int AddTen(int value) => value + 5 + 5;
}

[Patch]
abstract class AgeGatePatch : AgeGate
{
    [Inject(nameof(Allows), 18f, At.Constant)]
    float RaiseMinimumAge(float original)
    {
        return 20f;
    }
}

This finds the literal 18f inside Allows and replaces every occurrence with the injection's return value. Use by to narrow that down. 0 (the default) matches every occurrence. A 1-based value picks a single one when the constant appears more than once:

[Inject(nameof(AddTen), 5, At.Constant, by: 2)]
int BumpSecondFive(int original)
{
    return original + 1;
}

by: 2 matches the second emitted 5 literal and leaves the first alone.

At.Constant patches based on the compiler output, not source text. A constant match is only as stable as the IL the compiler happens to emit. A later source change can move the literal, fold it into a different constant, or drop it from the method entirely. Any of these changes stops the injection from matching. Treat it the way you'd treat any patch on generated code, and re-check it after changing the target method.

Constructor body injections

Constructor injections omit the target method name.

At.Head

Patch a constructor

Drop the method name. An [Inject] with no method targets the declaring type's constructor:

[Patch]
abstract class ActorConstructionPatch : GameActor
{
    [Inject(At.Head)]
    void OnConstruct(ControlHandle ch)
    {
        Logger.Info("A target actor is being constructed.");
    }
}

This runs at the head of GameActor's parameterless constructor, before the original body. For an overloaded constructor, name the parameter types. The same parameterTypes: argument selects which one:

[Inject(At.Head, parameterTypes: [typeof(FactionId)])]
void OnConstructWithFactionId(ControlHandle ch) { }

Or fluently:

Patcher.ForConstructor<GameActor>([typeof(FactionId)])
    .Head(typeof(ActorConstructionPatch), "OnConstructWithFactionId")
    .Apply();

This works for instance constructors only. Static constructors (.cctor) run when a type is first touched, usually before a patch could apply, so Concord does not support them. A constructor body patch also cannot change which type gets built or skip the new operation. Constructor-call matching is planned but does not ship yet.

Patch targets and member access

Target one overload of a method

When the target method has overloads, the name alone is ambiguous. Pass the parameter types to pick one:

[Patch]
abstract class StackPatch : ItemStack
{
    [Inject(At.Head, nameof(Add), parameterTypes: [typeof(int)])]
    void BeforeAddInt(ControlHandle ch)
    {
        Logger.Info("Adding by count.");
    }
}

This targets Add(int) and leaves Add(Item) untouched. Use parameterTypes: on ordinary and constant injections. On the invoke attribute form, use targetParameterTypes: for the outer target and invokeParameterTypes: for the matched call. The fluent target selector also accepts parameter types:

Patcher.For<ItemStack>(nameof(ItemStack.Add), [typeof(int)])
    .Head(typeof(StackPatch), "BeforeAddInt")
    .Apply();

For an overloaded call-site method, pass parameter types to restrict the match. Without them, Concord matches every call instruction with that declaring type and method name. Pass the types to PatchBuilder.Invoke(...), or use invokeParameterTypes: on [Inject].

Target an inaccessible nested type

Use a string target when C# cannot name the target type. CLR nested type names use + between the outer and inner type:

[Patch("Game.Rendering.BlockRenderer+AmbientPass")]
abstract class AmbientPassPatch
{
    [Inject(At.Head, "Render")]
    void BeforeRender()
    {
        Logger.Info("Rendering the ambient pass.");
    }
}

Concord resolves the name from loaded assemblies. An assembly-qualified type name can disambiguate two assemblies that contain the same full name.

Access the target instance

When the patch declaration extends its target, this is the current target object inside an injection:

[Patch]
abstract class DoorPatch : Door
{
    [Inject(At.Head, nameof(Open))]
    void BeforeOpen()
    {
        Logger.Info($"Opening {this}.");
    }
}

If the declaration cannot extend the target, expose the same object with [InjectInstance], as shown next.

Read or write a private field

Declare a field with the same type and map it to the target field by name:

[Patch]
abstract class HealthPatch : GameActor
{
    [InjectField("hitPoints")]
    private int hitPoints;

    [Inject(At.Tail, nameof(TakeDamage))]
    void AfterTakeDamage()
    {
        if (hitPoints < 1)
        {
            hitPoints = 1;
        }
    }
}

Use private-field access sparingly. Prefer public or protected members when the target already exposes what you need. At runtime:

public void TakeDamage(int amount)
{
    hitPoints -= amount;

    if (hitPoints < 1)
        hitPoints = 1;
}

Concord.Generators can generate this [InjectField] declaration from [Shadow("hitPoints")], as shown in Generate private member declarations.

Access members on a target you cannot inherit

Use an explicit target plus injected member declarations for a sealed target or any other type the patch declaration cannot extend:

[Patch(typeof(SealedFurnace))]
abstract class FurnacePatch
{
    [InjectInstance]
    protected abstract SealedFurnace Self { get; }

    [InjectProperty("Temperature")]
    protected abstract int Temperature { get; set; }

    [InjectMethod("Recalculate")]
    protected abstract int Recalculate(int amount);

    [Inject(At.Tail, nameof(SealedFurnace.Tick))]
    void AfterTick()
    {
        Temperature = Math.Max(0, Temperature);
        Logger.Info($"{Self}: {Recalculate(5)}");
    }
}

[InjectInstance] exposes the current target object. [InjectProperty] and [InjectMethod] map the declarations to target members with matching types and signatures. [InjectField] does the same for a field, as shown in the previous section.

Concord.Generators can generate those field, property, and method declarations. The next section shows the [Shadow] form.

Generate private member declarations

Projects that reference Concord.Generators can use [Shadow] instead of writing each injected member declaration. Mark the patch declaration partial so the generator can add the members:

[Patch]
[Shadow("hitPoints")]
[Shadow("Recalculate", typeof(int))]
abstract partial class HealthPatch : GameActor
{
    [Inject(At.Tail, nameof(TakeDamage))]
    void AfterTakeDamage()
    {
        this.hitPoints = this.Recalculate(0);
    }
}

Roslyn runs the generator as part of the build, so you never start it yourself.

[Shadow("hitPoints")] adds a private field to the generated part of HealthPatch. The field has the same type as GameActor.hitPoints and carries [InjectField("hitPoints")], which makes this.hitPoints valid C#. Rider can complete the name while the compiler checks its type. When Concord builds the patch, it rewrites the access to the real private field on the current GameActor.

[Shadow("Recalculate", typeof(int))] adds a typed [InjectMethod] member, and the typeof(int) argument selects its overload. The compiler checks this.Recalculate(0) before Concord calls the private target method. A property shadow adds an [InjectProperty] member in the same way.

Pick good patch declaration names

Patch declaration names should say what they change. FreeStarterItemsPatch is good. Patch1 is not. Patch names show up in debugging and diagnostics.

Reverse patches

Call the unpatched original

To run the original from inside a wrap, you don't need this section. Both a whole-method Around and an invoke Around call original.Invoke(...) on their Operation handle (see Wrap the whole method). Use a reverse patch when you need a standalone delegate to the original body from anywhere, bypassing every patch on the method. Use ReversePatchFactory.Bind:

MethodBase getPrice = typeof(ShopItem).GetMethod(nameof(ShopItem.GetPrice))!;

var original = (Func<ShopItem, int>)ReversePatchFactory.Bind(getPrice, typeof(Func<ShopItem, int>));

int originalPrice = original(item);

Useful when an injection needs to compare patched behavior against the target's unmodified output. Most mods won't need reverse patches.

Attached data

Use attached data from a patch

Add using Concord.AttachedData;, then use AttachedField<TTarget, TValue> when a patch needs per-instance data that is not a real field on the target type:

[Patch]
abstract class ActorExtensions : GameActor
{
    private static readonly AttachedField<GameActor, int> CustomHealth = new();

    [Inject(At.Tail, nameof(TakeDamage))]
    private void AfterTakeDamage()
    {
        if (CustomHealth.Get(this) < 0)
        {
            CustomHealth.Set(this, 0);
        }
    }
}

The static AttachedField owns a weak table keyed by each GameActor. Its values stay in memory while their target objects are alive. Core does not save them. See Attached Data for Get, Set, TryGet, and persistence details.