[IL2CPP][CE]大侠立志传MOD实战之商店物品数量锁定99
前言:
首先感谢3DM Mod群里剑圣大佬的耐心指点。
1. 如果你完全不了解Unity游戏MOD开发的基础,那么推荐先去看一下宵夜大佬的教程:
【太吾绘卷】Mod开发教程之HelloWorld、反编译、配置文件、Harmony示例
2. 如果你不了解IL2CPP如何安装MOD,请先阅读以下文章:
3. 如果你不了解IL2CPP MOD的一些入口点,以及环境搭建,请先阅读以下文章:
Unity Il2cpp 游戏的 Mod 制作教程03 - HelloWorld
虽然上面的文章看起来很复杂,但是新手不用有太大的压力。像我本人压根没学过C#,但是看多了别人写的代码,猜也能猜个七七八八。
本文主要是用大侠立志传这款游戏,分享下我个人从汇编入手,寻找函数,以及梳理函数内部逻辑的思路,希望能给新手一些指引。同时也希望大佬们能指出我的不足之处并提出优化的方法,算是抛砖引玉吧。

IL2CPP的不同点:
最近新接触了大侠立志传,想要写个MOD,这才发现原来有个东西叫做IL2CPP,而在它的基础上去编写MOD与以往不同。
以前吧,反编译下Assembly-CSharp.dll,全部导出后开始关键词瞎猜环节。猜的感觉差不多以后,分析函数,查看调用,最后确定想要Patch的目标函数。
但是这一套流程,在IL2CPP里就不那么适用了。因为Assembly-CSharp.dll(安装BepInEx后BepInEx\interop文件夹下)里的函数,反编译出来是这个样子的:

这代码看的我是一脸懵逼、两眼一黑、三魂出窍,完全不知所云。关键是所有的函数,大同小异都长这样,这咋整?
等我冷静下来仔细分析了一下后,才逐渐明白了过来。
本质上这里的Assembly-CSharp.dll只是提供了函数名,类名,结构体名等等之类的数据,具体的函数代码被游戏隐藏了起来,只通过dnSpy是看不到。
打个通俗易懂的比方吧。就好比酿酒,Assembly-CSharp只提供给你一个酒瓮,你只知道要往里面加米和水,最终会从酒瓮里倒出酒来。但是这个从米+水变成酒的过程,被酒瓮给遮住了,你是看不见的。
虽然过程不可见,但是这并不代表Assembly-CSharp是无用的,至少它可以告诉我们酒瓮里要加的东西是米和水,最终倒出来的是酒而不是醋。
同理,在进行HarmonyPatch的时候,我们可以通过控制加的米水的量,最终影响到酒的产量。当然也可以通过调用酿酒函数的方法,不在酒坊,而在家里直接酿酒。同时也需要注意,以往有个常用的手段,即通过Transpiler直接修改函数内部的某个关键点,使得最终的产出物由酒变成醋。这种手段在IL2CPP里并不适用,至少目前我没找到Transpiler的方法,欢迎大佬们指点下迷津。
那么问题来了,dnSpy里看不到函数代码,不代表别的地方看不到。剑圣大佬告诉我,可以用Il2CppDumper配合IDA来查看代码。但是吧,IDA我还没有玩明白,所以我决定使用我比较顺手且擅长的CheatEngine来解决问题。

修改思路:
首先,我们要明确:
我们的目的是把商店出售物品的数量锁定为99,那么至少我们得先在CE里找到该数量的内存地址,然后寻找哪里的代码改变或者读取了它的数值,定位该段代码的函数头。通过CE里Activate mono features这个功能,

得到函数名,最终确定目标函数。

寻找商品库存数量:
从常理上理解,每当购买一个物品,库存数-1。所以直接在CE里尝试搜索数值,然后你会发现完全搜不到。
这是为啥呢?因为这游戏的逻辑是这样的:物品库存=每日允许购买最大数量-已购买数量。
所以直接搜库存是搜不到的,如果你尝试搜索已购买数量,会得到精确地址,也能快捷的定位到关键代码段。
但是假设,我们不知道物品库存是个什么样的逻辑结构,也不知道它是否被加密。在这种情况下该如何解决问题?
我选择的切入口是购买数量。

通过不断增减购买数量,在CE里查找到购买数量的地址,然后把它改成99(超过库存),点击确定,发现啥都没发生。钱没减,物品也没增加,购买失败。
这就非常棒了!购买失败说明了,必定有一段代码,读取了购买数量,然后将购买数量与库存进行比较,发现超出上限,最终程序判定购买失败。
换言之,我们只要跟踪购买数量的数据传递,就能找到那个关键的cmp,最终定位到库存哪里来的。
以下是详细的追码过程,冗长枯燥毫无技术含量,老手可以直接跳过(往下搜"WuLin.FactionGoodItem.get_Stock")。新手还是请跟我走一遍,了解下追码究竟怎么追。
首先我们对购买数量的地址下断,查找读取,然后点击确定购买一个物品,追过去

根据64位fastcall的调用约定,函数的参数依次储存在rcx,rdx,r8,r9。超出的部分存入栈中。
这也意味着追码过程中,如果数据储存在rcx,rdx,r8,r9其中任意一个寄存器,那么遇到call的时候必须跟进去查看数据传递。如果储存在其他的寄存器,比如rax,rbx,rdi等等之类,那么在遇到call的时候可以直接步过,不需要进入查看。
那么很显然,这里购买数量是作为第二个参数传入rdx,然后马上调用了GameAssembly.dll+3F1BA0。那么我们只能跟进去查看rdx是如何被传递数值的,

上图可以发现,edx的值传给了edi。继续走,

这里edi传给了[rbx+14]。根据此时rbx的值,添加地址rbx+14,然后对该地址下断查找哪里访问,最后重新购买物品,触发断点,跟过去,

此时,ebx=购买数量。往下走,

随后ebx传给了eax,作为返回值直接return。说明这是一个获取物品购买数量的函数。追它返回到哪里,

刚返回就发现,eax=购买数量,作为第二个参数传给了edx,然后调用函数GameAssembly.dll+3EF8D0,跟进去看看,

edx传给了ebp,继续走,

ebp又传给了edx,接着往下,

edx传给了edi,继续,

edi传给了[rbx+14],这里同样对[rbx+14]下断,查找访问,发现再次来到了GameAssembly.dll+3F03CD

这说明
这段代码会传递购买数量两次,那么简化下操作,直接对GameAssembly.dll+3F03CD下断,然后购买物品,第一次触发断点放走,然后会第二次触发断点,此时再继续跟进,查看它的返回函数,

这里可以看到,eax=购买数量,传给了edi,往下走,

这里是不是就开始明朗了?edi传给了edx,随后调用WuLin.TradingWithFactionManager.BuyItem。这个函数名看起来就亲切,顺便说一下,好像要启用CE里的Activate mono features功能才能看到这里的函数名,我记不清楚了。这个选项的启动位置可以看上文“修改思路”里的截图。
我们跟进函数看一下,

edx传给了r12,往下走,

这里r12作为第三个参数传给了r8,此时r8=购买数量,随后调用WuLin.FactionInstance.GetGood,跟进去看看,

r8传给了ebp,继续走,

我们终于找到了那个关键的cmp。通过函数名,我们可以推测WuLin.FactionGoodItem.get_Stock返回的是库存数值,接着库存和购买数量进行对比,最后判断购买是否成功。这也就意味着,如果我们把jl给nop掉,那么先前那个修改购买数量为99,然后点击购买的行为就会被判定成功,也就能超出库存上限的购买物品。

关键函数:WuLin.FactionGoodItem.get_Stock
我们在dnSpy里定位下这个函数,

这就很有意思了,这张图包含了两个关键点。
1. Stock这个属性下,有且只有Getter,并没有Setter。同时该class下,还没有任何和stock有关的私有成员。
当时我是想破了脑袋也没想明白这是为啥,直到我请教了剑圣大佬。说的通俗点呢,Stock其实分为两个部分,一个壳子,两个真实地址。FactionGoodItem.Stock就是个壳子,在这个壳子里会先获取到两个真实地址,之后进行运算,最终得出Stock。
那么聪明如我,大脑已经开始疯狂运算了。不管它函数内部的代码是啥,真实地址又在哪里,我只要把FactionGoodItem.get_Stock的返回值给恒定在99,不就可以锁定商品为99了么?
于是我开始了如下尝试
我觉得自己实在是太机智了。编译后进游戏一看,购买失败!
这是为什么呢?因为游戏获取库存时,不会每次都调用FactionGoodItem.get_Stock。有些时候,它会直接读取两个真实地址,相减后获得stock,再用stock去做别的运算。而当程序这么走的时候,压根不会调用FactionGoodItem.get_Stock,自然我们的修改也就失效了。
所以为了从根本上解决问题,我们还是得去分析get_Stock内部代码逻辑是什么。这也就来到了上图所展示的第二个关键点。
2. WuLin.FactionGoodItem.get_Stock是个无参call。说是无参call,但实际上还是有一个参数的,即rcx=this=FactionGoodItem。

get_Stock内部代码逻辑:
回到CE,直接Go to address WuLin.FactionGoodItem.get_Stock,对这个函数进行追踪,得出它的返回值eax哪里来的。
因为要追返回值,最方便的是从下往上追,所以我们先来到函数底部,

这里可以看出来,返回值eax由两个部分组成,即
Stock = edi - call GameAssembly.dll+7793F0的返回值
也就是说我们要追的数据分为两个部分。
1. edi
往上走,可以看到,

edi = [rax+30],继续往上追rax哪儿来的,

rax = [rbx+20],即:edi = [[rbx+20]+30]。继续往上走,追rbx哪里来的,

这里可以看到,rbx=rcx=this=FactionGoodItem。由此我们可以得知:
edi = [[FactionGoodItem+20]+30]
2. call GameAssembly.dll+7793F0的返回值

很明显,这个call有三个参数,rcx,rdx,r8。其中r8固定为一个基址里的值,暂时不用追。我们来看看此时寄存器中rcx和rdx的值:

凭我多年的经验,rdx很有可能就是所购买商品的物品ID,也确实如此,

由此可见,rdx=rbx=rax=StockId。
接着,我们需要追一下,rcx这个参数怎么来的。
rcx是由rsi传过去的,往上走追rsi,

参数rcx = rsi = [rax+40],继续往上,

rax = [rbx+10], 即参数rcx = [[rbx+10]+40],继续往上追可以得到rbx=FactionGoodItem,所以对于call GameAssembly.dll+7793F0来说,它的第一个参数rcx = [[FactionGoodItem+10]+40]
至此,我们可以总结下目前得到的结论
Stock = [[FactionGoodItem+20]+30] - call GameAssembly.dll+7793F0的返回值
call GameAssembly.dll+7793F0 参数1 = [[FactionGoodItem+10]+40],参数2=StockId,参数3=某个基址。
那么接下来,我们就需要通过CE了解下这些偏移代表着什么。
首先,确定了勾选Activate mono features以后,点击.Net Info

然后选择第一个domain→Assembly-CSharp.dll→FactionGoodItem,

此时在右边的窗口里,我们可以看到下图,

即:
[FactionGoodItem+10] = factionInstance
[FactionGoodItem+20] = factionGoodData
接着,继续在本窗口搜索这俩成员的Type,即WuLin.FactionInstance & GameData.FactionGoodsData,寻找[FactionInstance+40]以及[FactionGoodsData+30],截图如下:


已知:
[FactionGoodItem+10] = factionInstance
[FactionGoodItem+20] = factionGoodData
再加上由上图可知:
[FactionInstance+40] = goodsModified
[FactionGoodsData+30] = Stock
那么可以推论:
[[FactionGoodItem+10]+40] = FactionGoodItem.factionInstance.goodsModified
[[FactionGoodItem+20]+30] = FactionGoodItem.factionGoodData.Stock
进一步可知:
WuLin.FactionGoodItem.get_Stock的返回值
Stock = FactionGoodItem.factionGoodData.Stock - 某个函数的返回值(该函数参数1=FactionGoodItem.factionInstance.goodsModified,参数2=StockId,参数3=某个地址)
为了搞懂那个某函数,我们需要先搞懂它的参数。所以先回dnSpy看一下这个goodsModified究竟是个啥,

这里是个Dictionary<int, int>的结构。那么不妨大胆猜测下,key = StockId,value = 已购买数量,那个某函数其实就是Dictionary.TryGetValue,或者类似的函数。
至此WuLin.FactionGoodItem.get_Stock这个函数基本上就理清了结构,我猜它可能是这样写的

HarmonyPatch:
有了函数内部代码,patch还是不是手到擒来。
既然库存=允许购买上限-已经购买数量。那么每当获取库存时,把允许购买上限修改成99,同时已购买数量清零即可达成目标:商店物品锁定99。
完整代码如下

结尾:
至此,本次IL2CPP mod制作的实战教学完成。撒花完结。
最后我想补充一点,所谓条条大路通罗马,一道题的解法有千万种,我的解法只是其中的沧海一粟,花这么多功夫写出来也是希望能给大家一点启发。应该被推崇的是举一反三、随机应变的思维方式,最忌讳的就是思维固化、一成不变。