通杀爆改 Unity FPS 游戏系列-第三章:il2cpp mono 差异
# 索引[通杀爆改 Unity FPS 游戏系列-序章](https://www.52pojie.cn/thread-1829474-1-1.html)
[通杀爆改 Unity FPS 游戏系列-第一章](https://www.52pojie.cn/thread-1830230-1-1.html)
[通杀爆改 Unity FPS 游戏系列-第二章](https://www.52pojie.cn/thread-1832964-1-1.html)
# 本章内容
本篇先给出 mono 和 il2cpp 的一些差异 (理论)
然后再以 windows mono 包为例实现功能(实际):
- 全屏改血
- 全屏秒杀
最后对比第二章中 il2cpp 包,再次说明 il2cpp 和 mono 差异(理论结合实际)
------
# 理论
mono 和 il2cpp 最大的差异点在于它们的编译方式
## 编译方式
| | 编译方式 | 说明 |
| ------ | -------- | ---------------------- |
| il2cpp | AOT | Ahead Of Time 提前编译 |
| mono | JIT | Just In Time 即时编译|
------
对不熟悉的小伙伴们看到这里可能依旧一头雾水,这里再引入一个词:IL
## IL
IL 全称:Intermediate Language ,中间语言
所谓的中间,自然意味着只是个**临时的过渡的**内容,并不是我们真正去执行的代码
这里就不贴过于官方的定义了,谈谈我个人的理解
### 作用
IL 的作用就是为了实现跨平台
不同平台对应的底层代码(汇编)并不相同
下面给出**通常**平台下对应的汇编,windows 也有 arm 版本,也有模拟器运行的安卓是 x86,这里说的是通常情况
重点在于说明不同平台的底层代码并不相同,如果想要实现跨平台需要编译四个对应平台的可执行文件(汇编代码)
| | 32位汇编 | 64位汇编| 对应可执行文件(例) |
| ------- | ----------- | --------- | ------------------------------ |
| Windows | x86 | x64 | UnityPlayer.dll (32 位 + 64位) |
| Android | armeabi-v7a | arm64-v8a | libunity.so(32位 + 64位) |
------
### 联系
编译的路径是:
我们编写游戏的 C# 代码 → IL → 对应平台可执行的汇编代码
而 AOT 和 JIT 的区别就在于
AOT:提前编译,IL 只是在编译的时候中转一下,最终编译出来的游戏包就是对应平台的可执行文件,不包含 IL
JIT:即时编译,IL 是包含在最终生成的游戏包的,游戏在运行过程中动态地将 IL 编译成对应平台的可执行代码
------
再来个例子类比下:
同一道菜,比如西红柿炒鸡蛋,不同人(不同平台)的口味不同(汇编代码),有的人喜欢甜口(x86),有的人喜欢咸口(armeabi-v7a)
在上菜前,直接把盐或者糖放了,然后再问客人要吃什么口味再上对应的菜,叫 AOT ,提前编译
在上菜后,问客人要吃什么口味,然后再放对应的盐或者糖,叫做 JIT ,即时编译
IL 是还没有放盐和放糖的西红柿炒蛋,加盐和加糖相当于是编译成对应客人想要的菜
------
再加一个**编译优化**的概念
用上面的例子来说,就是放盐和放糖只是让菜符合客人的口味,而放多少,放哪里 就算是编译优化了
这里先点一下,后面会具体说明
------
# 实际
## 全屏改血
### 功能
依旧按照第二章的方式来操作
先下双击 fps.AI 下的 Unity.FPS.AI.EnemyController.Update 函数,跳转到函数头部,然后快捷键下断点(这部分在第一章和详细演示过,这里稍微简略些)
!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230914095713979.png)
查看寄存器和堆栈数据
!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230914100208176.png)
------
根据函数原型可以推断出堆栈内容的含义:(忘记如何推断的可以回顾 [通杀爆改 Unity FPS 游戏系列-第一章](https://www.52pojie.cn/thread-1830230-1-1.html) 中的改血部分,说明了函数原型和对应堆栈的联系)
| 内容| 含义 | |
| :---- | :------- | ----------------------------------------- |
| ESP | 1EABF33B | 完成调用后要返回的内存地址 |
| ESP+4 | 1A1DED00 | 我们想要的指向 EnemyController 的内存地址 |
------
把得到的地址丢进 Structure dissect 验证 ESP+4 对应的地址就是 EnemyController
!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230914101318305.png)
------
整理下前面得到的信息:
同时记下要改的 CurrentHeatlh 对应的偏移:
| | 内容 |
| -------------------- | --------------- |
| ESP | 返回地址 |
| ESP+4 | EnemyController |
| EnemyController+0060 | m_Health |
| m_Health+20 | CurrentHealth |
------
开始 Auto assemble 写脚本,关于脚本部分在第二章已经说得比较详细了,这里不再赘述
直接给出脚本代码:
```assembly
//code from here to '' will be used to enable the cheat
alloc(newmem,2048)
label(returnhere)
label(originalcode)
label(exit)
newmem: //this is allocated memory, you have read,write,execute access
//place your code here
//随便找个寄存器保存,因为中途需要借助寄存器来赋值,这里也可以改成 ebx ecx ...
push eax
//注意这个地方,原本 EnemyController 的地址应该是 ESP+4 但是因为我们保存了个寄存器,导致位置变化,所以需要再加 4
//这里就相当于是 eax = EnemyController
mov eax,
//这里就相当于是 eax = EnemyController+60 = m_Health
mov eax,
//这里就相当于是 = = = 0
mov ,(float)0
//用完以后要还原回去,保存的是什么寄存器就还原什么寄存器
pop eax
originalcode:
push ebp
mov ebp,esp
push edi
sub esp,74
exit:
jmp returnhere
Unity.FPS.AI.EnemyController:Update:
jmp newmem
nop 2
returnhere:
//code from here till the end of the code will be used to disable the cheat
dealloc(newmem)
Unity.FPS.AI.EnemyController:Update:
db 55 8B EC 57 83 EC 74
//push ebp
//mov ebp,esp
//push edi
//sub esp,74
```
------
### 对比 il2cpp
使用文本比较工具 (https://down.52pojie.cn/Tools/Editors/Beyond.Compare.Pro.v4.x.Windows.CracKed.v2.7z) 对比 il2cpp 的修改代码和 mono 的修改代码
!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230914105325275.png)
------
可以看到有 3 个区别:
| mono | il2cpp | 差异说明 | 补充 |
| ----------------------------------- | ------------------------- | ---------------------------- | --------------- |
| mov eax, | mov eax, | 结构体偏移有所区别 | 编译差异 |
| push edi<br/>sub esp,74 | push -01 | 原本要执行的汇编逻辑有所区别 | 编译差异 |
| Unity.FPS.AI.EnemyController:Update | "GameAssembly.dll"+1F4800 | 要 HOOK 的地址有所差别 | JIT 和 AOT 导致 |
这里重点说明一下要 HOOK 地址的差别
| | 内容 | 说明 | 地址 |
| ------ | ----------------------------------- | --------------- | ---------------------------------- |
| mono | Unity.FPS.AI.EnemyController:Update | 函数名 | JIT 即时编译,每次重开游戏都会变动 |
| il2cpp | "GameAssembly.dll"+1F4800 | 模块名 + 偏移量 | AOT 提前编译,固定不变 |
这里不难看出,无论是 mono 还是 il2cpp ,我们的分析思路都是适用的,不存在所谓的 mono 太旧太过时,il2cpp 较新而有差异分析修改
差异最大的点,反而在 CE 修改器为我们生成的修改模板上,我们自己写的修改逻辑并无差异
PS:如果要我们自己写代码实现这个 HOOK ,mono 确实会比较麻烦,毕竟 mono 的地址是会变动的,得 JIT 后才能拿到
------
## 全屏秒杀
### 死亡函数
在第二章里特地留了个差异点,就是全屏秒杀的死亡函数fps.Game → Unity.FPS.Game.Health → HandleDeath
只能在 mono 的情况下才会生效,在 il2cpp 时**根本不会走到** HandleDeath 这个函数
先使用这个死亡函数实现全屏秒杀,再对比说明原因
现在 .NET Info 里找到 HandleDeath:
!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230918134527570.png)
------
双击跳转到对应的汇编,按 F5 下断点,然后打敌人一枪,发现命中后会触发断点:
可以推测出该函数的作用:在敌人扣血时,判断血量是否小于等于 0 ,如果是则执行死亡逻辑
!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230918134952220.png)
------
### Health死亡
确定了 HandleDeath 的作用后,就可以在全屏改血的基础上,再调用它实现全屏秒杀
在[通杀爆改 Unity FPS 游戏系列-第二章](https://www.52pojie.cn/thread-1832964-1-1.html)中已经说明了如何在汇编中调用函数
要注意的点有 2 个:
- 函数的参数
- 堆栈平衡
------
先看函数原型:
System.Void HandleDeath()
是最简单的函数,没有参数也没有返回值
但在第二章中也说过:在汇编中相比函数原型,会多出一个参数:指向该类自身的指针(对 HandleDeath 来说就是 Health)
因此可以先一步给出伪代码:
```assembly
//给参数
push m_Health
//调用死亡函数
call Unity.FPS.Game.Health:HandleDeath
//堆栈外平衡
add esp,4
```
------
有了伪代码,就可以在全屏改血的基础上,加上这部分的逻辑
```assembly
newmem: //this is allocated memory, you have read,write,execute access
//place your code here
//随便找个寄存器保存,因为中途需要借助寄存器来赋值,这里也可以改成 ebx ecx ...
push eax
//注意这个地方,原本 EnemyController 的地址应该是 ESP+4 但是因为我们保存了个寄存器,导致位置变化,所以需要再加 4
//这里就相当于是 eax = EnemyController
mov eax,
//这里就相当于是 eax = EnemyController+60 = m_Health
mov eax,
//这里就相当于是 = = = 0
mov ,(float)0
//-------------从这里开始是调用死亡函数的逻辑 -----------------
//改完血以后准备调用死亡函数,再拿到 EnemyController
mov eax,
//通过 EnemyController 再拿到 m_Health
mov eax,
push eax
call Unity.FPS.Game.Health:HandleDeath
add esp,4
//-------------从这里结束了调用死亡函数的逻辑 -----------------
//用完以后要还原回去,保存的是什么寄存器就还原什么寄存器
pop eax
originalcode:
```
------
最后附上完整的修改逻辑
```assembly
//code from here to '' will be used to enable the cheat
alloc(newmem,2048)
label(returnhere)
label(originalcode)
label(exit)
newmem: //this is allocated memory, you have read,write,execute access
//place your code here
//随便找个寄存器保存,因为中途需要借助寄存器来赋值,这里也可以改成 ebx ecx ...
push eax
//注意这个地方,原本 EnemyController 的地址应该是 ESP+4 但是因为我们保存了个寄存器,导致位置变化,所以需要再加 4
//这里就相当于是 eax = EnemyController
mov eax,
//这里就相当于是 eax = EnemyController+60 = m_Health
mov eax,
//这里就相当于是 = = = 0
mov ,(float)0
//-------------从这里开始是调用死亡函数的逻辑 -----------------
//改完血以后准备调用死亡函数,再拿到 EnemyController
mov eax,
//通过 EnemyController 再拿到 m_Health
mov eax,
push eax
call Unity.FPS.Game.Health:HandleDeath
add esp,4
//-------------从这里结束了调用死亡函数的逻辑 -----------------
//用完以后要还原回去,保存的是什么寄存器就还原什么寄存器
pop eax
originalcode:
push ebp
mov ebp,esp
push edi
sub esp,74
exit:
jmp returnhere
Unity.FPS.AI.EnemyController:Update:
jmp newmem
nop 2
returnhere:
//code from here till the end of the code will be used to disable the cheat
dealloc(newmem)
Unity.FPS.AI.EnemyController:Update:
db 55 8B EC 57 83 EC 74
//push ebp
//mov ebp,esp
//push edi
//sub esp,74
```
------
### OnDie死亡
il2cpp 版本中因为 Health 的 HandleDeath 不会被调用到,所以使用的是fps.Game.dll → Unity.FPS.AI.EnemyController → OnDie
这个函数在 mono 版本同样是有效的,可以直接调用实现全屏秒杀的效果
实际的分析和逻辑和 il2cpp 别无二致,这里为了精简篇幅,直接贴出对应的代码:
```
//code from here to '' will be used to enable the cheat
alloc(newmem,2048)
label(returnhere)
label(originalcode)
label(exit)
newmem: //this is allocated memory, you have read,write,execute access
//place your code here
//随便找个寄存器保存,因为中途需要借助寄存器来赋值,这里也可以改成 ebx ecx ...
push eax
//注意这个地方,原本 EnemyController 的地址应该是 ESP+4 但是因为我们保存了个寄存器,导致位置变化,所以需要再加 4
//这里就相当于是 eax = EnemyController
mov eax,
//参数是 EnemyController
push eax
//调用 OnDie 函数
call Unity.FPS.AI.EnemyController:OnDie
//堆栈外平衡
add esp,4
//用完以后要还原回去,保存的是什么寄存器就还原什么寄存器
pop eax
originalcode:
push ebp
mov ebp,esp
push edi
sub esp,74
exit:
jmp returnhere
Unity.FPS.AI.EnemyController:Update:
jmp newmem
nop 2
returnhere:
//code from here till the end of the code will be used to disable the cheat
dealloc(newmem)
Unity.FPS.AI.EnemyController:Update:
db 55 8B EC 57 83 EC 74
//push ebp
//mov ebp,esp
//push edi
//sub esp,74
```
------
不难发现,和 il2cpp 版本的区别只有 call 调用函数的区别
| | 代码 | 说明 |
| ------ | --------------------------------------- | -------------------------------- |
| il2cpp | call GameAssembly.dll+1F2D50 | AOT 固定的地址:模块名+偏移量 |
| mono | call Unity.FPS.AI.EnemyController:OnDie | JIT 不固定的地址:直接使用函数名 |
------
# 分析
这里的分析主要是针对 il2cpp 和 mono 中 Health.HandleDeath 这个死亡函数的调用差异
首先要明确的是:无论是 il2cpp 和 mono,它们的源代码是一样的
下面先给出源代码,然后再结合实际的执行逻辑分析
## 源代码
下面的源代码来自于 Health.cs ,也就是 Health 这个类中,注释部分为个人补充
```c#
//受伤函数,当要扣血时会调用到,参数一个是受到的伤害量,还有一个是伤害源
public void TakeDamage(float damage, GameObject damageSource)
{
//是否开启全局无敌,无敌则屏蔽所有受伤判定
if (GlobalInvincible)
return;
float healthBefore = CurrentHealth;
CurrentHealth -= damage;
CurrentHealth = Mathf.Clamp(CurrentHealth, 0f, MaxHealth);
// call OnDamage action
float trueDamageAmount = healthBefore - CurrentHealth;
if (trueDamageAmount > 0f)
{
OnDamaged?.Invoke(trueDamageAmount, damageSource);
}
//这里为调用死亡函数
HandleDeath();
}
//这个函数是当人物从高处坠落时摔死用到的,因为有调用到 HandleDeath 所以贴进来
//这次分析和这个函数无关
public void Kill()
{
CurrentHealth = 0f;
// call OnDamage action
OnDamaged?.Invoke(MaxHealth, null);
HandleDeath();
}
//死亡函数
void HandleDeath()
{
//已经死了就直接返回
if (m_IsDead)
return;
// call OnDie action
//判定当前血量是否小于等于 0 ,小于等于 0 则调用 OnDie
if (CurrentHealth <= 0f)
{
m_IsDead = true;
OnDie?.Invoke();
}
}
```
------
## 实际执行逻辑
看了前面的源代码,HandleDeath 确实是在每次扣血时就会调用到
但是符合预期的只有 mono 版 ,il2cpp 版却并没有调用到
因此需要进一步分析 il2cpp 实际的代码逻辑
以 TakeDamage 为突破口,查看其实际的逻辑(注意这里分析的是 il2cpp 包)
!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230918165520821.png)
------
双击跳转到对应的汇编代码段:
记下此时的偏移量 1F9940
!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230918170109998.png)
------
然后使用 IDA Pro 分析这部分逻辑的伪代码
限于篇幅,这里略去操作部分,关于 IDA Pro 分析 Unity 游戏留到后面的篇章
直接给出解析到的伪代码:
!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230918170544099.png)
------
```cpp
int __cdecl sub_101F9940(int a1, float a2)
{
int result; // eax
float v4; // xmm0_4
float v5; // xmm2_4
int *savedregs; //
int savedregsb; //
int savedregsc; //
float retaddr; //
if ( !byte_1114E2F7 )
{
savedregs = &dword_110E8900;
((void (*)(void))sub_10164120)();
byte_1114E2F7 = 1;
}
result = *(_DWORD *)(dword_110E8900 + 92);
if ( !*(_BYTE *)result )
{
v4 = *(float *)(a1 + 32) - a2;
if ( v4 < 0.0 )
{
v4 = 0.0;
}
else if ( v4 > *(float *)(a1 + 12) )
{
v4 = *(float *)(a1 + 12);
}
v5 = *(float *)(a1 + 32) - v4;
*(float *)(a1 + 32) = v4;
if ( v5 > 0.0 )
{
result = *(_DWORD *)(a1 + 20);
if ( result )
{
retaddr = v5;
savedregsb = *(_DWORD *)(result + 32);
result = (*(int (**)(void))(result + 12))();
}
}
if ( !*(_BYTE *)(a1 + 36) && *(float *)(a1 + 32) <= 0.0 )
{
result = *(_DWORD *)(a1 + 28);
*(_BYTE *)(a1 + 36) = 1;
if ( result )
{
savedregsc = *(_DWORD *)(result + 32);
result = (*(int (**)(void))(result + 12))();
}
}
}
return result;
}
```
------
伪代码里有个明显且关键的地方:
```cpp
if ( !*(_BYTE *)(a1 + 36) && *(float *)(a1 + 32) <= 0.0 ){
.....
}
```
这里可以对应到:
```c#
void HandleDeath() {
......
if (CurrentHealth <= 0f)
{
......
}
}
```
------
## 结论
il2cpp 包直接将 HandleDeath 原本调用函数的逻辑去掉,换成直接把 HandleDeath 函数里的内容摁过来,所以不再去调用原本的函数
这个地方就要提到前面说的点:编译优化
可以发现 HandleDeath 这个函数本身的代码量很少,并没有多少
函数调用是有消耗的,因为在调用函数前,需要保存上下文信息,调用完后再还原
编译优化,就把原有的函数调用替换成了函数内的逻辑,直接执行,少了一层函数调用
因此 il2cpp 包并不会调用到 HandleDeath
------
# 补充
补充下 il2cpp 和 mono 包的目录结构 对比
限于篇幅,这里直接说结论,有兴趣的小伙伴可以自己拿 Demo 的 il2cpp 和 mono 包对比验证
| | 游戏逻辑文件 | 说明 | 编译方式 |
| ------ | --------------------------------------- | -------------------------------------- | -------- |
| il2cpp | GameAssembly.dll | 编译成对应平台的可执行文件(动态链接库) | AOT |
| mono | FpsDemo_By_lyl610abc_Data\Managed\*.dll | 编译成 IL 文件,运行过程中会动态再编译 | JIT |
------
# 总结
- il2cpp 和 mono 的核心差异在于**编译方式**:AOT 和 JIT
- il2cpp 和 mono 都会生成 **IL 中间代码**,区别在于一个直接在打包阶段再编译成对应平台可执行文件(AOT),另一个则是在运行阶段才去编译(JIT)
- 无论是 il2cpp 还是 mono , 都能够通过 CE 修改器的 **mono** 功能分析结构,查找实例等
- il2cpp 定位函数是以 **模块名 + 偏移量** (GameAssembly.dll + offset),mono 定位函数则是直接以**函数名** (如 Unity.FPS.Game.Health:HandleDeath)
- il2cpp 由于**编译优化**可能会产生通过 mono 找到的函数不被调用的情况
本章作为番外,算是稍微解答了一些关于 il2cpp 和 mono 的异同点,希望能对大家有所帮助
不同的编译方式对于分析的影响其实并没有有些人想象的那么多,只要能够抓住本质,基本上是通用的
本章还是留下了一些坑没填:
- CE 修改器的 mono 功能是怎么实现的
- 不依赖 CE 修改器自实现修改
- IDA Pro 分析游戏逻辑
-------------------ヾ(•ω•`)o----------------------------
## 预告
坑没填完没关系,再挖些坑债多不压身,预告下后面的内容
通过 mono 功能好像没有办法找到人物的坐标,如何实现全图瞬移?
FPS 游戏最常用的功能透视自瞄在 Unity 上如何实现?
## 附件
最后附上 mono 版的 CE 修改器脚本:[点我下载](https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/FpsDemo_By_lyl610abc_mono.CT)
!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230918195431994.png) hongshao987 发表于 2023-9-18 20:32
楼主搞个二十年前的单机游戏来演示一下吧,不然我等好像看天书。
要从序章开始看,包含了单机游戏的演示{:301_999:} 那些基础的东西还没整明白呢,直接看懂了感觉就跟被雷劈死一般难。{:301_1008:} 国际豆哥 发表于 2024-6-1 13:27
好久都没看到我大哥发新东西了
{:301_998:} 其实有不少
深藏不露{:301_997:} 看的一脸萌~~ Youame 发表于 2023-9-18 20:24
看的一脸萌~~
需要结合前面的章节看~~{:301_975:} 楼主搞个二十年前的单机游戏来演示一下吧,不然我等好像看天书。 哈哈哈!认真学习!!!{:300_961:} 好诶,更新了。不过这章怎么没有正己相关的内容了。 奇怪的知识增加了 技术研究很有乐趣