Target a local variable
A target method's local variables hold values that never reach a parameter or a return value. Concord reaches them in two ways:
- A
[Local]parameter on an injection binds one local, so the injection reads it. ALocalHandle<T>parameter also writes it. - An
At.Localinjection attaches to each read or write of a local, the way an invoke injection attaches to a call.
Both need Concord to pick one slot out of the target body. That choice is what the selectors below do.
Select the local
Set at most one selector. With none set, the parameter's own type picks the local.
| Selector | Example | What it matches |
|---|---|---|
| Type only | [Local] int doubled |
The one local of that type |
Ordinal |
[Local(Ordinal = 2)] int doubled |
The second int, in slot order |
Index |
[Local(Index = 4)] int doubled |
Raw slot 4 |
Name |
[Local(Name = "doubled")] int doubled |
The local the symbols name doubled |
Type only is the default and the one to reach for first. A target with two locals of that type raises CONC147 rather than a guess.
Ordinal counts the locals of the parameter's type in slot order, and skips every other slot. It stays correct while the target declares the same locals of that type in the same order.
Index names a raw slot. It is exact, and any recompile of the target can move it. A slot of another type raises CONC150.
Name reads the target's symbols. It is the least portable selector and it does nothing on RimWorld. See Selecting by name.
Selecting by name
Name is the least portable selector, and it is not the recommended one. It reads the target's symbols, so it needs a pdb next to the assembly file on disk. Three things break it:
- A host that loads an assembly from bytes leaves
Assembly.Locationempty, so Concord finds no pdb and raisesCONC151. An embedded pdb does not help, because the byte array is gone by then. RimWorld loads every mod assembly this way. Assembly-CSharp, the RimWorld game assembly, ships no pdb at all. No adapter can makeNamereach it.- One name can cover more than one slot. A debug build gives two
for (int i ...)loops one name across two slots, which raisesCONC163.
An adapter fixes the first point with Patcher.UseLocalNameResolver, which maps a loaded module to the file its symbols sit beside:
Patcher.UseLocalNameResolver(module => ModRoots.PathOf(module));
On RimWorld, Name resolves nothing until an adapter calls that, and it never reaches the game assembly. Use the type, Ordinal, or Index there.
Read a local
A [Local] parameter binds a slot and reads it:
[Patch]
abstract class ShopPatch : Shop
{
[Inject(At.Return, nameof(Total))]
static void Report([Local] int doubled)
{
Ledger.Record(doubled);
}
}
A read is legal at At.Return, At.Tail, At.Finally, At.Local, and the At.Head and At.Tail shifts of an invoke or construction injection. Every other position raises CONC146 or CONC158, because the target body has not filled its slots yet, or the injection never sees them.
The parameter gives you a copy. You cannot write the target's local through it. Assigning it raises
CONCORD049 at compile time and CONC164 at patch time. So does passing it as a ref or out
argument. Writing a field of a struct local, such as spot.X = 999, raises CONCORD049 too. That
write lands on the copy, and the target never sees it. Use a LocalHandle<T> to write. One parameter also cannot carry both
[Capture] and [Local]: that raises CONCORD050 and CONC165.
Write a local
A LocalHandle<T> parameter reads and writes the slot through its Value property:
[Patch]
abstract class ShopPatch : Shop
{
[Inject(nameof(Total), typeof(Ledger), nameof(Ledger.Record), At.Tail)]
static void Bump([Local] LocalHandle<int> doubled)
{
doubled.Value += 1;
}
}
No handle instance exists at run time. Concord lowers each Value read to an ldloc and each Value write to an stloc while it composes the wrapper, and the parameter disappears. Four rules follow from that. The first three raise CONC161 and the fourth raises CONC156:
- Every use is a direct
Valueread or write on the parameter. Storing the handle, passing it to a call, capturing it in a lambda, or returning it are all rejected. - A
Valueaccess cannot sit across a conditional. Writeifandelsearms with a plain assignment in each one, noth.Value = flag ? 1 : 2. - Nothing constructs a
LocalHandle<T>. A handle arrives as a parameter or not at all. - The position must leave the write readable.
At.Return,At.Tail, andAt.Finallyrun after the body is done with its locals, so a write there is dead and raisesCONC156. UseAt.Localor an invoke or construction shift.
The At.Local contract
At.Local attaches the injection to reads or writes of one local:
[Patch]
abstract class ShopPatch : Shop
{
[Inject(nameof(Total), typeof(int), LocalAccess.Store, At.Local)]
static int Bump(int total)
{
return total + 1;
}
}
The injection is a value injection. It takes the local's type and returns the replacement.
| Part | Meaning |
|---|---|
LocalAccess.Store |
Matches stloc and nothing else. The return value overwrites what the body wrote. |
LocalAccess.Load |
Matches ldloc and nothing else. The return value replaces the loaded value, and the slot keeps its own. |
By |
The 1-based occurrence to target. 0 targets every match. |
[Slice] |
Bounds both matching and By counting to a range of the body. |
By counts against a copy of the target method body, taken before any injection splices into it. One mod's injection never renumbers another mod's occurrences. See Counting occurrences with by.
What Store can see
LocalAccess.Store matches the stloc family and nothing else. That is every assignment written by value, and it is not every write.
A write through the local's address leaves no stloc. Concord rejects the part of that it can prove. The body can take the address and hand it straight to stind, stobj, cpobj, or initobj. A store injection never fires for that write, so the selection raises CONC154 rather than composing into a patch that does nothing.
An address handed to a call is a different case, and Concord allows it:
int n = 0;
int.TryParse(s, out n); // writes n through the address, no stloc
At.Local(Store) on n fires once, for the int n = 0 initializer, and misses the write the author cares about. A mutating method on a struct local is the same shape:
Vector2 v = default;
v.Mutate(); // writes v through the address, no stloc
Concord does not reject either one. To decide, Concord must prove the called method writes nothing, and IsReadOnlyAttribute cannot show that. Single and DateTime mark the type rather than the member, and the net472 runtime library predates the attribute. Its absence proves nothing, so Concord does not guess.
The rule has to stay this narrow. Almost every instance method call on a struct local takes the local's address. A rule against every address-take would reject points.ToString() too, which makes the position unusable on float, int, DateTime, and every user struct.
So read Store as the assignments Concord can see. When an injection looks like it never fires, check whether something writes that local through its address.
Limits
| Limit | What happens |
|---|---|
| A release build reuses slots | One slot can carry two same-typed locals in separate scopes, so a bound slot may hold another variable elsewhere in the body. |
| A loop local holds the last iteration | A read at a late position such as At.Return gets that value, or the default when the loop never ran. |
LocalAccess.Store misses indirect writes |
An out or ref argument, and a mutating call on a struct local, write through an address and leave no stloc, so the injection never fires for them. Concord allows the selection. See What Store can see. |
| Another mod's transpiler can move a slot or a count | Concord drops a selector that a foreign edit broke. An added slot can make one ambiguous. A By that held against the target's own IL can stop holding. The owning mod gets a report, and every other mod on the target stays applied. |
| No Mono.Cecil in the API | Selectors are attributes and plain types. Concord never hands a mod an IL object. |
A dropped selector is the only failure Concord does not throw for. A selector that would have failed
against the target body on its own is your own mistake, so it throws. To see a drop, give Concord a
log at startup with Patcher.UseLog(message => ...). Without one the report goes to
System.Diagnostics.Trace, which most hosts do not listen to.
Diagnostics
| Code | Cause |
|---|---|
CONC145 |
A selector sets more than one of Ordinal, Index and Name. Keep one |
CONC146 |
A [Local] parameter at At.Head, which runs before the body assigns a local |
CONC147 |
The type alone matches more than one local. Set Ordinal |
CONC148 |
No local of that type is in range |
CONC149 |
Index names a slot past the end of the body |
CONC150 |
Index names a slot of another type |
CONC151 |
Name needs symbols the host cannot read |
CONC152 |
The symbols name no local by that name |
CONC153 |
The selected slot is pinned or a byref, which cannot bind |
CONC154 |
A store injection on a slot the body writes through its address with stind, stobj, cpobj, or initobj |
CONC155 |
By names an occurrence the body does not have |
CONC156 |
A LocalHandle<T> write at a position where the body is done with its locals |
CONC158 |
A [Local] or LocalHandle<T> parameter at a position that binds no local |
CONC159 |
A load injection on the slot that carries the method's result |
CONC160 |
A [Local] or LocalHandle<T> parameter on a whole-method At.Around |
CONC161 |
A LocalHandle<T> used as anything other than a direct Value read or write |
CONC162 |
The selected slot has no read and no write in the composed body |
CONC163 |
Name covers more than one slot. Set Ordinal or Index |
CONC164 |
An injection assigns to a parameter Concord binds for it, such as [Local] |
CONC165 |
One parameter carries both [Capture] and [Local] |