协程钩取
迭代器函数
迭代器函数, 也即方法体带 yield return
或 yield break
语句并返回 IEnumerable
或 IEnumerator
的函数,
它允许你 "中断" 函数的运行并中途 "返回" 一个值. 经过前面 Coroutine 节的介绍相信你也知道到了协程之于迭代器函数的强大.
不过对于协程函数的钩取并不是那么简单, 需要一些额外步骤.
如果你相对了解一点 C# 的底层的话, 你应该会知道迭代器函数最终会被编译为一个状态机类, 而原函数只是做了一个 new 并返回的工作.
为了在反编译器比如 dnspy 中浏览这个状态机类, 你需要关闭类似的 视图 -> 选项 -> 反编译器 -> 反编译枚举器 这个功能, 这样反编译器才会展示隐藏的状态机类.
比如 FinalBoss.Attack01Sequence
方法:
原方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 | private IEnumerator Attack01Sequence()
{
this.StartShootCharge();
for (;;)
{
yield return 0.5f;
this.Shoot(0f);
yield return 1f;
this.StartShootCharge();
yield return 0.15f;
yield return 0.3f;
}
yield break;
}
|
关闭 反编译枚举器 选项后:
| private IEnumerator Attack01Sequence()
{
FinalBoss.<Attack01Sequence>d__49 <Attack01Sequence>d__ = new FinalBoss.<Attack01Sequence>d__49(0);
<Attack01Sequence>d__.<>4__this = this;
return <Attack01Sequence>d__;
}
|
其中 <Attack01Sequence>d__49
这个古怪的类名就是背后生成的状态机类, 而方法体的实际内容则在该类的 MoveNext
方法中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37 | bool IEnumerator.MoveNext()
{
int num = this.<>1__state;
FinalBoss finalBoss = this.<>4__this;
switch (num)
{
case 0:
this.<>1__state = -1;
finalBoss.StartShootCharge();
break;
case 1:
this.<>1__state = -1;
finalBoss.Shoot(0f);
this.<>2__current = 1f;
this.<>1__state = 2;
return true;
case 2:
this.<>1__state = -1;
finalBoss.StartShootCharge();
this.<>2__current = 0.15f;
this.<>1__state = 3;
return true;
case 3:
this.<>1__state = -1;
this.<>2__current = 0.3f;
this.<>1__state = 4;
return true;
case 4:
this.<>1__state = -1;
break;
default:
return false;
}
this.<>2__current = 0.5f;
this.<>1__state = 1;
return true;
}
|
上述代码中 <>2__current
这个古怪的字段用来储存返回值, <>1__state
则用来储存协程运行的进度.
On 协程钩子
对协程使用 On 钩子相对简单一点 ,例如钩取 Celeste.NPC01_Theo.Talk
这个协程函数, 也就是 1a 6zb 面的 theo 的对话的协程,
如果你直接使用如下代码(方法 1):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 | public override void Load()
{
On.Celeste.NPC01_Theo.Talk += NPC01_Theo_Talk;
}
private IEnumerator NPC01_Theo_Talk(On.Celeste.NPC01_Theo.orig_Talk orig, NPC01_Theo self, Player player)
{
var it = orig(self, player);
Logger.Log(LogLevel.Info, "Test", "not the right time.");
return it;
}
public override void Unload()
{
On.Celeste.NPC01_Theo.Talk -= NPC01_Theo_Talk;
}
|
你会发现它实际上是在对话开始前输出的, 这并不是我们想要的时机, 同样这也很好理解为什么会这样, 因为原函数只是返回了一个背后状态机类的新实例.
为了达成这个目的, 我们需要这么做(方法 2):
| private IEnumerator NPC01_Theo_Talk(On.Celeste.NPC01_Theo.orig_Talk orig, NPC01_Theo self, Player player)
{
IEnumerator origEnum = orig(self, player);
while (origEnum.MoveNext()) yield return origEnum.Current;
Logger.Log(LogLevel.Info, "Test", "the right time.");
}
|
也就是将对应的协程包装起来并在最后附加我们的代码. 说到这个, 你可能会想到这段代码可以简化成这样(方法 3):
| private IEnumerator NPC01_Theo_Talk(On.Celeste.NPC01_Theo.orig_Talk orig, NPC01_Theo self, Player player)
{
yield return orig(self, player);
Logger.Log(LogLevel.Info, "Test", "does not execute.");
}
|
直接将协程返回并在其执行完后做一些事, 看起来似乎没什么问题? 但是! 如果你看过 Coroutine
的实现的话,
你会发现协程返回另一个协程时, 另一个协程并不是马上执行的, 而是等到了下一帧, 为了更好的兼容 tas, 我们要么使用方法 2, 要么使用如下类似的代码(方法 4):
| private IEnumerator OuiFileSelect_Leave(On.Celeste.OuiFileSelect.orig_Leave orig, OuiFileSelect self, Oui next)
{
yield return new SwapImmediately(orig(self, next));
Logger.Log("TestMod", "I left file select!");
}
|
也就是在获取协程后再用 everest 为我们提供的 SwapImmediately
包起来, 这会让内部的协程立刻前进一次. 而不会等待多余的 1 帧.
Info
具体上述方法的行为描述的可能并不是很准确, 因为我个人很少会出现需要钩取协程的案例, 很感谢如果你能完善它的话!
IL 协程钩子
对协程使用 IL 钩子相对会复杂很多, 因为对原函数使用 IL 钩子通常是没有意义的, 为此, 我们需要获取到背后实际储存方法体的状态机的 MoveNext
方法:
| var methodInfo = typeof(Player).GetMethod("DashCoroutine", BindingFlags.NonPublic | BindingFlags.Instance).GetStateMachineTarget();
ILHook dashCoroutineHook = new ILHook(methodInfo, ILHookDashCoroutine);
|
在这里, MonoMod 为我们提供了一个很方便的拓展方法 GetStateMachineTarget
, 它会获取这个方法对应的状态机的 MoveNext
方法,
随后我们手动构造一个 IL 钩子, 然后就像往常一样实现我们的钩子. 不过当然, 难度会非常大, 因为通常这个方法是非常混乱的.