[IL2CPP][CE]大俠立志傳MOD實戰(zhàn)之商店物品數(shù)量鎖定99
前言:
首先感謝3DM Mod群里劍圣大佬的耐心指點。
1. 如果你完全不了解Unity游戲MOD開發(fā)的基礎,那么推薦先去看一下宵夜大佬的教程:
【太吾繪卷】Mod開發(fā)教程之HelloWorld、反編譯、配置文件、Harmony示例
2.?如果你不了解IL2CPP如何安裝MOD,請先閱讀以下文章:
3.?如果你不了解IL2CPP MOD的一些入口點,以及環(huán)境搭建,請先閱讀以下文章:
Unity Il2cpp 游戲的 Mod 制作教程03 - HelloWorld
雖然上面的文章看起來很復雜,但是新手不用有太大的壓力。像我本人壓根沒學過C#,但是看多了別人寫的代碼,猜也能猜個七七八八。
本文主要是用大俠立志傳這款游戲,分享下我個人從匯編入手,尋找函數(shù),以及梳理函數(shù)內(nèi)部邏輯的思路,希望能給新手一些指引。同時也希望大佬們能指出我的不足之處并提出優(yōu)化的方法,算是拋磚引玉吧。

IL2CPP的不同點:
最近新接觸了大俠立志傳,想要寫個MOD,這才發(fā)現(xiàn)原來有個東西叫做IL2CPP,而在它的基礎上去編寫MOD與以往不同。
以前吧,反編譯下Assembly-CSharp.dll,全部導出后開始關鍵詞瞎猜環(huán)節(jié)。猜的感覺差不多以后,分析函數(shù),查看調(diào)用,最后確定想要Patch的目標函數(shù)。
但是這一套流程,在IL2CPP里就不那么適用了。因為Assembly-CSharp.dll(安裝BepInEx后BepInEx\interop文件夾下)里的函數(shù),反編譯出來是這個樣子的:

這代碼看的我是一臉懵逼、兩眼一黑、三魂出竅,完全不知所云。關鍵是所有的函數(shù),大同小異都長這樣,這咋整?
等我冷靜下來仔細分析了一下后,才逐漸明白了過來。
本質上這里的Assembly-CSharp.dll只是提供了函數(shù)名,類名,結構體名等等之類的數(shù)據(jù),具體的函數(shù)代碼被游戲隱藏了起來,只通過dnSpy是看不到。
打個通俗易懂的比方吧。就好比釀酒,Assembly-CSharp只提供給你一個酒甕,你只知道要往里面加米和水,最終會從酒甕里倒出酒來。但是這個從米+水變成酒的過程,被酒甕給遮住了,你是看不見的。
雖然過程不可見,但是這并不代表Assembly-CSharp是無用的,至少它可以告訴我們酒甕里要加的東西是米和水,最終倒出來的是酒而不是醋。
同理,在進行HarmonyPatch的時候,我們可以通過控制加的米水的量,最終影響到酒的產(chǎn)量。當然也可以通過調(diào)用釀酒函數(shù)的方法,不在酒坊,而在家里直接釀酒。同時也需要注意,以往有個常用的手段,即通過Transpiler直接修改函數(shù)內(nèi)部的某個關鍵點,使得最終的產(chǎn)出物由酒變成醋。這種手段在IL2CPP里并不適用,至少目前我沒找到Transpiler的方法,歡迎大佬們指點下迷津。
那么問題來了,dnSpy里看不到函數(shù)代碼,不代表別的地方看不到。劍圣大佬告訴我,可以用Il2CppDumper配合IDA來查看代碼。但是吧,IDA我還沒有玩明白,所以我決定使用我比較順手且擅長的CheatEngine來解決問題。

修改思路:
首先,我們要明確:
我們的目的是把商店出售物品的數(shù)量鎖定為99,那么至少我們得先在CE里找到該數(shù)量的內(nèi)存地址,然后尋找哪里的代碼改變或者讀取了它的數(shù)值,定位該段代碼的函數(shù)頭。通過CE里Activate?mono?features這個功能,

得到函數(shù)名,最終確定目標函數(shù)。

尋找商品庫存數(shù)量:
從常理上理解,每當購買一個物品,庫存數(shù)-1。所以直接在CE里嘗試搜索數(shù)值,然后你會發(fā)現(xiàn)完全搜不到。
這是為啥呢?因為這游戲的邏輯是這樣的:物品庫存=每日允許購買最大數(shù)量-已購買數(shù)量。
所以直接搜庫存是搜不到的,如果你嘗試搜索已購買數(shù)量,會得到精確地址,也能快捷的定位到關鍵代碼段。
但是假設,我們不知道物品庫存是個什么樣的邏輯結構,也不知道它是否被加密。在這種情況下該如何解決問題?
我選擇的切入口是購買數(shù)量。

通過不斷增減購買數(shù)量,在CE里查找到購買數(shù)量的地址,然后把它改成99(超過庫存),點擊確定,發(fā)現(xiàn)啥都沒發(fā)生。錢沒減,物品也沒增加,購買失敗。
這就非常棒了!購買失敗說明了,必定有一段代碼,讀取了購買數(shù)量,然后將購買數(shù)量與庫存進行比較,發(fā)現(xiàn)超出上限,最終程序判定購買失敗。
換言之,我們只要跟蹤購買數(shù)量的數(shù)據(jù)傳遞,就能找到那個關鍵的cmp,最終定位到庫存哪里來的。
以下是詳細的追碼過程,冗長枯燥毫無技術含量,老手可以直接跳過(往下搜"WuLin.FactionGoodItem.get_Stock")。新手還是請跟我走一遍,了解下追碼究竟怎么追。
首先我們對購買數(shù)量的地址下斷,查找讀取,然后點擊確定購買一個物品,追過去

根據(jù)64位fastcall的調(diào)用約定,函數(shù)的參數(shù)依次儲存在rcx,rdx,r8,r9。超出的部分存入棧中。
這也意味著追碼過程中,如果數(shù)據(jù)儲存在rcx,rdx,r8,r9其中任意一個寄存器,那么遇到call的時候必須跟進去查看數(shù)據(jù)傳遞。如果儲存在其他的寄存器,比如rax,rbx,rdi等等之類,那么在遇到call的時候可以直接步過,不需要進入查看。
那么很顯然,這里購買數(shù)量是作為第二個參數(shù)傳入rdx,然后馬上調(diào)用了GameAssembly.dll+3F1BA0。那么我們只能跟進去查看rdx是如何被傳遞數(shù)值的,

上圖可以發(fā)現(xiàn),edx的值傳給了edi。繼續(xù)走,

這里edi傳給了[rbx+14]。根據(jù)此時rbx的值,添加地址rbx+14,然后對該地址下斷查找哪里訪問,最后重新購買物品,觸發(fā)斷點,跟過去,

此時,ebx=購買數(shù)量。往下走,

隨后ebx傳給了eax,作為返回值直接return。說明這是一個獲取物品購買數(shù)量的函數(shù)。追它返回到哪里,

剛返回就發(fā)現(xiàn),eax=購買數(shù)量,作為第二個參數(shù)傳給了edx,然后調(diào)用函數(shù)GameAssembly.dll+3EF8D0,跟進去看看,

edx傳給了ebp,繼續(xù)走,

ebp又傳給了edx,接著往下,

edx傳給了edi,繼續(xù),

edi傳給了[rbx+14],這里同樣對[rbx+14]下斷,查找訪問,發(fā)現(xiàn)再次來到了GameAssembly.dll+3F03CD

這說明
這段代碼會傳遞購買數(shù)量兩次,那么簡化下操作,直接對GameAssembly.dll+3F03CD下斷,然后購買物品,第一次觸發(fā)斷點放走,然后會第二次觸發(fā)斷點,此時再繼續(xù)跟進,查看它的返回函數(shù),

這里可以看到,eax=購買數(shù)量,傳給了edi,往下走,

這里是不是就開始明朗了?edi傳給了edx,隨后調(diào)用WuLin.TradingWithFactionManager.BuyItem。這個函數(shù)名看起來就親切,順便說一下,好像要啟用CE里的Activate?mono?features功能才能看到這里的函數(shù)名,我記不清楚了。這個選項的啟動位置可以看上文“修改思路”里的截圖。
我們跟進函數(shù)看一下,

edx傳給了r12,往下走,

這里r12作為第三個參數(shù)傳給了r8,此時r8=購買數(shù)量,隨后調(diào)用WuLin.FactionInstance.GetGood,跟進去看看,

r8傳給了ebp,繼續(xù)走,

我們終于找到了那個關鍵的cmp。通過函數(shù)名,我們可以推測WuLin.FactionGoodItem.get_Stock返回的是庫存數(shù)值,接著庫存和購買數(shù)量進行對比,最后判斷購買是否成功。這也就意味著,如果我們把jl給nop掉,那么先前那個修改購買數(shù)量為99,然后點擊購買的行為就會被判定成功,也就能超出庫存上限的購買物品。

關鍵函數(shù):WuLin.FactionGoodItem.get_Stock
我們在dnSpy里定位下這個函數(shù),

這就很有意思了,這張圖包含了兩個關鍵點。
1.?Stock這個屬性下,有且只有Getter,并沒有Setter。同時該class下,還沒有任何和stock有關的私有成員。
當時我是想破了腦袋也沒想明白這是為啥,直到我請教了劍圣大佬。說的通俗點呢,Stock其實分為兩個部分,一個殼子,兩個真實地址。FactionGoodItem.Stock就是個殼子,在這個殼子里會先獲取到兩個真實地址,之后進行運算,最終得出Stock。
那么聰明如我,大腦已經(jīng)開始瘋狂運算了。不管它函數(shù)內(nèi)部的代碼是啥,真實地址又在哪里,我只要把FactionGoodItem.get_Stock的返回值給恒定在99,不就可以鎖定商品為99了么?
于是我開始了如下嘗試
我覺得自己實在是太機智了。編譯后進游戲一看,購買失??!
這是為什么呢?因為游戲獲取庫存時,不會每次都調(diào)用FactionGoodItem.get_Stock。有些時候,它會直接讀取兩個真實地址,相減后獲得stock,再用stock去做別的運算。而當程序這么走的時候,壓根不會調(diào)用FactionGoodItem.get_Stock,自然我們的修改也就失效了。
所以為了從根本上解決問題,我們還是得去分析get_Stock內(nèi)部代碼邏輯是什么。這也就來到了上圖所展示的第二個關鍵點。
2. WuLin.FactionGoodItem.get_Stock是個無參call。說是無參call,但實際上還是有一個參數(shù)的,即rcx=this=FactionGoodItem。

get_Stock內(nèi)部代碼邏輯:
回到CE,直接Go to address?WuLin.FactionGoodItem.get_Stock,對這個函數(shù)進行追蹤,得出它的返回值eax哪里來的。
因為要追返回值,最方便的是從下往上追,所以我們先來到函數(shù)底部,

這里可以看出來,返回值eax由兩個部分組成,即
Stock =?edi -?call GameAssembly.dll+7793F0的返回值
也就是說我們要追的數(shù)據(jù)分為兩個部分。
1.?edi
往上走,可以看到,

edi = [rax+30],繼續(xù)往上追rax哪兒來的,

rax = [rbx+20],即:edi =?[[rbx+20]+30]。繼續(xù)往上走,追rbx哪里來的,

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

很明顯,這個call有三個參數(shù),rcx,rdx,r8。其中r8固定為一個基址里的值,暫時不用追。我們來看看此時寄存器中rcx和rdx的值:

憑我多年的經(jīng)驗,rdx很有可能就是所購買商品的物品ID,也確實如此,

由此可見,rdx=rbx=rax=StockId。
接著,我們需要追一下,rcx這個參數(shù)怎么來的。
rcx是由rsi傳過去的,往上走追rsi,

參數(shù)rcx = rsi = [rax+40],繼續(xù)往上,

rax = [rbx+10],?即參數(shù)rcx = [[rbx+10]+40],繼續(xù)往上追可以得到rbx=FactionGoodItem,所以對于call?GameAssembly.dll+7793F0來說,它的第一個參數(shù)rcx = [[FactionGoodItem+10]+40]
至此,我們可以總結下目前得到的結論
Stock =?[[FactionGoodItem+20]+30] -?call?GameAssembly.dll+7793F0的返回值
call?GameAssembly.dll+7793F0?參數(shù)1 =?[[FactionGoodItem+10]+40],參數(shù)2=StockId,參數(shù)3=某個基址。
那么接下來,我們就需要通過CE了解下這些偏移代表著什么。
首先,確定了勾選Activate mono features以后,點擊.Net?Info

然后選擇第一個domain→Assembly-CSharp.dll→FactionGoodItem,

此時在右邊的窗口里,我們可以看到下圖,

即:
[FactionGoodItem+10] = factionInstance
[FactionGoodItem+20] = factionGoodData
接著,繼續(xù)在本窗口搜索這倆成員的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 -?某個函數(shù)的返回值(該函數(shù)參數(shù)1=FactionGoodItem.factionInstance.goodsModified,參數(shù)2=StockId,參數(shù)3=某個地址)
為了搞懂那個某函數(shù),我們需要先搞懂它的參數(shù)。所以先回dnSpy看一下這個goodsModified究竟是個啥,

這里是個Dictionary<int,?int>的結構。那么不妨大膽猜測下,key =?StockId,value =?已購買數(shù)量,那個某函數(shù)其實就是Dictionary.TryGetValue,或者類似的函數(shù)。
至此WuLin.FactionGoodItem.get_Stock這個函數(shù)基本上就理清了結構,我猜它可能是這樣寫的

HarmonyPatch:
有了函數(shù)內(nèi)部代碼,patch還是不是手到擒來。
既然庫存=允許購買上限-已經(jīng)購買數(shù)量。那么每當獲取庫存時,把允許購買上限修改成99,同時已購買數(shù)量清零即可達成目標:商店物品鎖定99。
完整代碼如下

結尾:
至此,本次IL2CPP?mod制作的實戰(zhàn)教學完成。撒花完結。
最后我想補充一點,所謂條條大路通羅馬,一道題的解法有千萬種,我的解法只是其中的滄海一粟,花這么多功夫寫出來也是希望能給大家一點啟發(fā)。應該被推崇的是舉一反三、隨機應變的思維方式,最忌諱的就是思維固化、一成不變。