杀手5逆向分析(三)

这一期主要分析对NPC造成伤害函数以及后座力函数。使用的工具有IDA Pro和Cheat Engine,还有利用Class Informer插件来简化工作量。


一,后座力函数的分析

1.定位函数

每次找指定的函数之前都建议用 Class Informer 插件来进行关键字搜索。比如我们这里需要查找跟后座力相关的类,那么我们就搜索关键字 Recoil 便能搜寻到相关的类:

S1

很幸运地只搜到了叫 ZHM5WeaponRecoil 的类,看名称就知道是跟武器后座力有关的。双击打开可以看到虚函数表,每一个虚函数点进去找到最长的那一个,F5 生成 C++ 伪代码之后就可以分析了。

2.函数伪代码大致分析

可以发现这个函数是通过 __thiscall 方式调用的,接受两个参数一个是this指针(指向武器对象),另一个是浮点数(可能与射击时间或强度有关)。把此函数命名为 weaponRecoil

1
void __thiscall weaponRecoil(int this, float a2);

现在通过 Cheat Engine 的动态分析可以确定的是 this+0x38 / 0x3C 分别是 X/Y轴当前视角的偏移值。第一个判断就涉及这两个参数,要是判断为真就会让视角不进行任何修改。

1
2
3
4
5
if ( 1.0 == *(float *)(this + 0x24) && 1.0 == *(float *)(this + 0x14) )// if true no recoil
{
*(_DWORD *)(this + 0x38) = 0;
*(_DWORD *)(this + 0x3C) = 0;
}

this + 0x24this + 0x14 这两个浮点数为1的情况下会执行重置视角偏移会变成无后座力的效果,在Cheat Engine 中删掉这个判断的话的确变成了无后座力(视角不会发生任何抖动)。那这里的两个参数可能是后座力恢复进度和后座力控制系数之类的参数。

要是有后座力效果,那就会执行else的内容。一开始就从输入参数当中提取某个值进行了计算并累积了这个值,可以称之为当前累计后座力。

1
2
3
4
a2 = (double)*(__int64 *)(LODWORD(a2) + 0x18) / 1048576.0;
a2 = *(float *)(this + 8) + a2; //this + 8 为当前累计后座力
v5 = a2;
*(float *)(this + 8) = a2; //保存此累计后座力

下面的判断可以推断出 this + 0xC 为后座力的阈值,只有超过这个阈值之后才会有后座力效果:

1
if ( *(float *)(this + 0xC) <= v5 )			// 检查是否超过阈值

3.函数算法分析

下面就是一系列详细的数学计算过程了,以下是大致分析没有进行动态分析验证,以供参考:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
else
{
// 从输入参数中提取后座力增量值
// 可能是从射击事件中获取数据并进行缩放
a2 = (double)*(__int64 *)(LODWORD(a2) + 0x18) / 1048576.0;

// 更新累积后座力值
a2 = *(float *)(this + 8) + a2; // this+8为当前累积后座力
v5 = a2;
*(float *)(this + 8) = a2; // 保存更新后的累积后座力

// 检查累积后座力是否超过阈值
if ( *(float *)(this + 0xC) <= v5 ) // this+0xC为后座力阈值
{
// 计算超出阈值的后座力量
a2 = v5 - *(float *)(this + 0xC);

// 处理后座力恢复逻辑
if ( *(float *)(this + 0x14) < 1.0 ) // 如果后座力未完全恢复
{
// 计算新的后座力恢复进度
v6 = a2 / *(float *)(this + 0x1C); // this+0x1C为后座力恢复速率参数
HIDWORD(v14) = *(_DWORD *)(this + 0x14); // 保存旧的恢复进度
*(float *)(this + 0x14) = v6; // 更新恢复进度

// 确保恢复进度不超过1.0
v7 = 0x3F800000; // 浮点数1.0的十六进制表示
if ( *(float *)(this + 0x14) <= 1.0 )
v7 = *(_DWORD *)(this + 0x14);
*(_DWORD *)(this + 0x14) = v7;

// 计算恢复过程中的视角偏移变化
v12 = sub_9DCE70(v7); // 可能是sin或cos函数
*((float *)&v14 + 1) = v12 - sub_9DCE70(HIDWORD(v14)); // 计算三角函数差值

// 计算X轴和Y轴的视角偏移增量
*(float *)&v14 = *(float *)(this + 0x30) * *((float *)&v14 + 1); // X轴偏移增量
*((float *)&v14 + 1) = *((float *)&v14 + 1) * *(float *)(this + 0x34); // Y轴偏移增量

// 更新当前视角偏移
*(float *)(this + 0x38) = *(float *)(this + 0x38) + *(float *)&v14; // 更新X轴偏移
*(float *)(this + 0x3C) = *(float *)(this + 0x3C) + *((float *)&v14 + 1); // 更新Y轴偏移
v4 = 1.0;
}

// 计算额外后座力
a2 = a2 - *(float *)(this + 0x1C); // 减去恢复速率参数

// 处理额外后座力效果
if ( a2 > 0.0 && *v3 < v4 ) // 如果有额外后座力且后座力控制因子小于1.0
{
// 如果后座力控制因子为0,初始化额外后座力参数
if ( *v3 == 0.0 )
{
// 保存当前视角偏移用于后续计算
v8 = *(_DWORD *)(this + 0x38); // 获取X轴视角偏移
*(_DWORD *)(this + 0x44) = *(_DWORD *)(this + 0x3C); // 保存Y轴视角偏移
v14 = *(float *)(this + 0x44); // 复制到临时变量
*(_DWORD *)(this + 0x40) = v8; // 保存X轴视角偏移

// 计算视角偏移的平方和(用于归一化)
*((float *)&v14 + 1) = *(float *)(this + 0x40) * *(float *)(this + 0x40) + v14 * v14;

// 计算平方根
SquareRoot(*((float *)&v14 + 1));

// 计算后座力强度参数
*(float *)(this + 0x28) = *((float *)&v14 + 1) / *(float *)(this + 0x18); // this+0x18: 后座力强度调节参数
}

// 更新后座力控制因子
v9 = *v3; // 保存旧的控制因子值
v10 = a2 / *(float *)(this + 0x28); // 计算新的控制因子值
a2 = 1.0;
HIDWORD(v14) = 0;
*v3 = v10; // 更新控制因子

// 限制控制因子在有效范围内
a2 = sub_93E700(v3, (char *)&v14 + 4, &a2); // 可能是min/max函数
v11 = a2;
*v3 = a2; // 保存调整后的控制因子

// 计算控制因子变化导致的视角偏移
v13 = sub_9DCE70(LODWORD(v11)); // 对新控制因子应用三角函数
a2 = v13 - sub_9DCE70(LODWORD(v9)); // 计算三角函数差值

// 计算X轴和Y轴的视角偏移值
*(float *)&v14 = -*(float *)(this + 0x40) * a2; // X轴偏移增量(负值表示反向修正)
*((float *)&v14 + 1) = a2 * -*(float *)(this + 0x44); // Y轴偏移增量(负值表示反向修正)

// 改变当前视角
*(float *)(this + 0x38) = *(float *)(this + 0x38) + *(float *)&v14; // 更新X轴最终视角
*(float *)(this + 0x3C) = *(float *)(this + 0x3C) + *((float *)&v14 + 1); // 更新Y轴最终视角
}
}
}

想要实现无后座力只需要把最开始的两个跳转指令Nop掉就行了:

1
2
0095ECFD                 jp      short loc_95ED1F
0095ED0B jp short loc_95ED1F

4.游戏后座力机制

  • 后座力一般会影响玩家的X,Y视角(上下,左右)
  • 后座力通常会随着时间进行恢复
  • 连续射击情况下会累积后座力大小
  • 后座力通常有阈值和最大限制值

二,造成伤害函数的分析

1.定位函数

在游戏中收到NPC伤害后执行的函数跟玩家对NPC造成伤害执行的函数是不同的。在部分游戏中只要是造成伤害都会使用同一个处理造成伤害的函数来处理,但是在杀手5中都是单独处理的。为了找到给对方造成伤害的函数我还是通过 Class Informer 插件进行关键字 Damage 的搜索,但是搜出来了4个:

S2
  • ZHM5SplashDamageEntity :可能负责范围伤害(如爆炸)的传播与计算。

  • ZHitmanDamageOverTime :可能处理持续性伤害(如中毒/燃烧)

  • ZHM5ForwardDamageToHitmanEntity :可能是伤害事件转发器,将外部伤害传递给Hitman实体,非计算核心

  • ZHM5DamageCounterEntity :可能是伤害统计计数器(如成就系统),记录而非计算

这个几个类的虚函数每一个都点进去一个个查看进行动态调试没有找到关于玩家对NPC造成伤害的具体函数。因此改变了方向,跟搜索玩家生命值函数的方法一样。我们已经知道生命值是浮点数计算的,那么伤害值也应该是浮点数,还有最大生命值为100。

通过一下方法找到NPC生命值参数:

  1. 在游戏中找到伤害比较低的武器
  2. 找一个固定的满生命值的NPC后通过Cheat Engine搜索100(浮点数)
  3. 对着NPC的腿部打一枪(为了让造成的伤害最小化防止NPC死亡)
  4. 搜索类型选择 减少的数值 继续搜索
  5. 对其他NPC造成伤害后搜索类型选择 未变化的值(为了减少搜索结果)
  6. 重复3 - 6 步骤,在保证NPC不死亡的情况下找到NPC的血量值

等找到了NPC的血量之后锁定此值并锁定,可以开很多枪进行验证是否正确。之后通过 Cheat Engine 的 Find out what writes to this address 功能找到相应的汇编代码。

S3

2.函数伪代码分析

将此汇编代码的地址复制到 IDA 当中,可以发现这个函数是通过 __thiscall 方式调用的,接受两个参数一个是this指针(指向NPC实体对象),另一个是浮点数(玩家对NPC造成的伤害值)。把此函数命名为 applyDamage

1
void __thiscall applyDamage(DWORD this, float damage);

第一行代码就是对生命值变量的修改,根据上下文可以知道 this + 0xA8 为NPC当前生命值,this + 0xA4 可能是NPC的最大生命值:

1
enemy_Health = damage + *(float *)(this + 0xA8);

新生命值 = 当前生命值 + 伤害值(应当为负),通过动态分析可以证实 damage 变量为负的浮点数。这样加上一个负值实际上是在减少生命值。

紧接着是保存NPC的最大生命值到临时变量后更新NPC目前的生命值:

1
2
v2 = *(float *)(this + 0xA4);
*(float *)(this + 0xA8) = enemy_Health;

后续就是一个 if 判断:

  • 最大生命值大于0(NPC活着)
  • 新计算出来的生命值要小于最大生命值
1
2
if ( v2 > 0.0 && *(float *)(this + 0xA4) > (double)enemy_Health )
*(_DWORD *)(this + 0xA8) = *(_DWORD *)(this + 0xA4);

如果满足以上两个条件的话,把当前生命值设为最大生命值。这就变得有点奇怪了,因为这个函数是计算玩家对NPC造成的伤害函数而这个判断是让NPC处于无敌状态的判断。为了解释这个异常行为通过动态分析查看 this + 0xA4 的值,发现这里的最大生命值一直都是显示为0,所以说此判断不会被正常执行。

我根据上面的判断条件对 this + 0xA4 的值进行修改并且让他大于当前生命值就能发现NPC变成了无敌状态。可以推断这里的判断可能是为了给某些NPC加上无敌效果(比如在过场动画中),或者是某种”第一次受伤”触发器(第一次受到伤害时恢复为满血),也有可能是开发者为了调试而留下的调试代码。

最后一个判断是个简单的检测生命值是否低于0,如果是则设置为0。这样就防止生命值为负,确保NPC死时生命值正好为0而不是负的:

1
2
if ( *(float *)(this + 0xA8) <= 0.0 )
*(_DWORD *)(this + 0xA8) = 0;

3.实际效果分析

在游戏当中,这个函数的行为是:

  1. 玩家攻击NPC后传入NPC对象和伤害值
  2. NPC的生命值减少
  3. 如果NPC的生命值为0或以下,NPC死亡并把生命值设为0
  4. 在特殊情况下,可能会使NPC无敌

4.函数完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
void __thiscall applyDamage(DWORD this, float damage)
{
double v2; // st7
float enemy_Health; // [esp+8h] [ebp+8h]

enemy_Health = damage + *(float *)(this + 0xA8);
v2 = *(float *)(this + 0xA4);
*(float *)(this + 0xA8) = enemy_Health;
if ( v2 > 0.0 && *(float *)(this + 0xA4) > (double)enemy_Health )
*(_DWORD *)(this + 0xA8) = *(_DWORD *)(this + 0xA4);
if ( *(float *)(this + 0xA8) <= 0.0 )
*(_DWORD *)(this + 0xA8) = 0;
}

因为逆向工程大部分都是搞猜测验证来进行的,所以文章中有错误猜测或错误分析的部分请在评论区纠正。有任何私人问题可以联系我的私人邮箱,邮箱地址在首页。谢谢观看!