INFO
Examples are taken from mod Lose Karma On Caught
with modifications.
Karma
The literal meaning of word karma
is not the same as crime/bounty/notoriety
, it's a self inspected, self reflected, state of consequences from ones' actions. However, Elin simply used the word karma
for its crime level system, so we'll take it that way.
Additional Skill Check
Sometimes you want a method call to be skipped inside another method, and sometimes you want to add your own call at certain places...It has been said in the previous pt.1.
In Elin, if you get caught during some crime actions, like digging up the thief's guild, again, you'll lose karma. But what if no one sees you? Let's see how to add an additional skill check whether players get caught or not committing crime, and apply actions accordingly.
Target Method
Stealing in Elin already has a skill check, which tests player's DEX + pickpocket skill against the witnesses' DEX, line of sight, and the loot rarity:
if (owner.pos.TryWitnessCrime(owner, chara, 4, delegate(Chara c) {
int num = ((!c.CanSee(owner)) ? 30 : 0);
int num2 = c.PER * 250 / 100;
if (target.isThing && (target.Thing.isEquipped ||
target.IsRangedWeapon || target.IsThrownWeapon)) {
num2 *= 2;
if (target.rarity >= Rarity.Legendary) {
num2 *= 2;
}
if (target.rarity >= Rarity.Artifact) {
num2 *= 2;
}
}
if (c.IsUnique) {
num2 *= 2;
}
return EClass.rnd(num2) > owner.Evalue(281) + owner.DEX + num;
})) {
p.Cancel();
}
However, for other crime actions like digging & mining(dwarfing) NPC owned property, players get a default check with TryWitnessCrime
which is a flat 10%
chance.
p.onProgress = delegate {
SourceMaterial.Row row = (pos.cell.HasBridge ? pos.cell.matBridge : pos.cell.matFloor);
owner.PlaySound(row.GetSoundImpact());
row.PlayHitEffect(pos);
row.AddBlood(pos);
owner.elements.ModExp(230, 5);
owner.renderer.NextFrame();
if (EClass._zone.IsCrime(owner, this)) {
owner.pos.TryWitnessCrime(owner);
}
};
Here's the target method to patch for both classes:
internal static IEnumerable<MethodInfo> TargetMethods() => [
AccessTools.Method(
AccessTools.FirstInner(typeof(TaskDig), t => t.Name.Contains("DisplayClass18_0")),
"<OnCreateProgress>b__1"),
AccessTools.Method(
AccessTools.FirstInner(typeof(TaskMine), t => t.Name.Contains("DisplayClass22_0")),
"<OnCreateProgress>b__1"),
];
Wonder why are the methodinfos like this?
C#
What we are trying to do here, is adding an additional custom skill check to the TryWitnessCrime
call, and reduce player's karma if they are caught - because in pt.1 we removed the unconditional karma loss.
In theory we are expecting this:
if (EClass._zone.IsCrime(owner, this)) {
var caught = owner.pos.TryWitnessCrime(owner, funcWitness: ourCustomCheck);
if (caught) {
EClass.player.ModKarma(-1);
}
}
IL with C#
Take a look at this code snippet's IL instructions(truncated for brevity):
// owner.pos.TryWitnessCrime(<>4__this.owner);
ldfld Point Card::pos
ldfld Chara AIAct::owner
ldnull
ldc.i4.4
ldnull
callvirt bool Point::TryWitnessCrime(Chara, Chara, int32, Func<Chara, bool>)
pop
That's quite a bit of IL instructions, let's analyze this backwards, starting from bottom:
pop
, it removes the value from stack top, which is the return value ofTryWitnessCrime
, discarded.callvirt
, the mthod call ofPoint.TryWitnessCrime
.ldnull
, parameters are pushed from left to right, so this is the rightmost parameter ofFunc<Chara, bool>
, the custom skill predicate.null
by default.ldc.i4.4
, pushes anint 4
onto the stack, which is the 3rd parameterradius
,4
by default.ldnull
, 2nd parameterChara target
, which isnull
by default.ldfld Chara ::owner
, 1st parameter, theChara owner
, which is theAIAct::owner
.ldfld Point ::pos
, the hiddenthis
parameter becauseTryWitnessCrime
is a class bound method ofPoint
.
This is equivalent of:
bool _ = pos.TryWitnessCrime(owner, null, 4, null);
Solution
The simple solution would be replacing the method call of Point::TryWitnessCrime
with our own method call, so it changes to:
ldfld Point Card::pos
ldfld Chara AIAct::owner
ldnull
ldc.i4.4
ldnull
call bool TryWitnessCustomCrime(Point, Chara, Chara, int32, Func)
pop
Notice the difference in the method signatures, the hidden this Point
instance becomes the 1st parameter, because our own TryWitnessCustomCrime
method is static and not class bound, which does not require a class instance to call. But we also cannot leave the this Point
instance on the stack, so we take it in as 1st parameter to maintain the stack balance.
Because the next pop
instruction is expecting a return value on the stack after the call
, so our own TryWitnessCustomCrime
method will also return a bool
value, even if it's discarded, to maintain the stack balance.
Implement
A basic Transpiler
patch, in an annotated patch class, note that we don't use [HarmonyPatch]
attribute here because we already used TargetMethods()
to define the method info.
internal static IEnumerable<CodeInstruction> Transpiler(
IEnumerable<CodeInstruction> instructions)
{
return new CodeMatcher(instructions)
.InstructionEnumeration();
}
Match
We use MatchEnd
to set the IL cursor at the line callvirt Point::TryWitnessCrime
:
return new CodeMatcher(instructions)
.MatchEndForward(
new CodeMatch(OpCodes.Callvirt, AccessTools.Method(
typeof(Point),
nameof(Point.TryWitnessCrime))))
.InstructionEnumeration();
Replace Call
Declare our own custom method, that adds a basic check of player's mining skill against witness's perception and line of sight status:
internal static bool TryWitnessCustomCrime(
Point pos,
Chara owner,
Chara? target,
int radius,
Func<Chara, bool> funcWitness)
{
var caught = pos.TryWitnessCrime(owner, funcWitness: w => {
var los = w.CanSee(owner) ? 0.5f : 0f;
var perception = w.PER / (2f - los);
return EClass.rnd((int)perception) > owner.Evalue("mining");
});
if (caught) {
EClass.player.ModKarma(-1);
}
}
After matching, we use SetInstruction
to change this IL line to our method call:
return new CodeMatcher(instructions)
.MatchEndForward(
new CodeMatch(OpCodes.Callvirt, AccessTools.Method(
typeof(Point),
nameof(Point.TryWitnessCrime))))
.SetInstruction(
Transpilers.EmitDelegate(TryWitnessCustomCrime))
.InstructionEnumeration();
Voilà
The complete patch:
// in a patch class
internal static bool TryWitnessCustomCrime(Point pos, Chara owner, Chara? target, int radius, Func<Chara, bool> funcWitness)
{
var caught = pos.TryWitnessCrime(owner, funcWitness: w => {
var los = w.CanSee(owner) ? 0.5f : 0f;
var perception = w.PER / (2f - los);
return EClass.rnd((int)perception) > owner.Evalue("mining");
});
if (caught) {
EClass.player.ModKarma(-1);
}
}
internal static IEnumerable<MethodInfo> TargetMethods() => [
AccessTools.Method(
AccessTools.FirstInner(typeof(TaskDig), t => t.Name.Contains("DisplayClass18_0")),
"<OnCreateProgress>b__1"),
AccessTools.Method(
AccessTools.FirstInner(typeof(TaskMine), t => t.Name.Contains("DisplayClass22_0")),
"<OnCreateProgress>b__1"),
];
internal static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions)
{
return new CodeMatcher(instructions)
.MatchEndForward(
new CodeMatch(OpCodes.Callvirt, AccessTools.Method(
typeof(Point),
nameof(Point.TryWitnessCrime))))
.SetInstruction(
Transpilers.EmitDelegate(TryWitnessCustomCrime))
.InstructionEnumeration();
}
That's it! You have added an additional skill check when players dig and mine illegal properties and can determine whether or not they should get caught and lose karma.