杀手5逆向分析(四)

这一期分析游戏的得分系统,我原本以为得分系统是很简单的其实没有那么简单。因为游戏采用了单独加分减分机制最终得分会根据当时一段时间的各种分数的加减来计算出来的。不跟据时间进行的计算会通过其他函数来进行写入操作,所以主要涉及更新得分的函数有两个。


分数函数的分析

1.查找得分地址

在游戏左上角可以看到得分系统其实都是按整数来计算的,在 Cheat Engine 当中我们直接按 4字节 来进行搜索,每干掉一个非目标NPC都会进行相应的惩罚并减分数。从0开始搜索,按照杀掉的NPC输入相应的负值再次反复搜索就能定位到分数地址。

S1

这里搜到的地址有5个而且都是静态地址,分数从游戏开始到结束都会存在并且不受环境因素影响(一会消失一会出现),所以一般都会以静态地址的方式来存储的。这里我们需要从这5个地址当中找到真正的分数地址,因此对每个地址的分数进行不停的修改直到找到真正的分数地址,最后锁定的正确地址为 HMA.exe+D64A78 。但是发现分数为正的时候是正常存储的,为负的时候变成了很大一串数字。这其实是因为补码的形式存储负数而导致的,补码最高位是符号位,0表示正数1表示负数。所以负数正常看的情况下会变得很大,更详细的介绍可以参考此介绍来学习。

2.锁定得分函数

使用Cheat Engine的 Find out what writes to this address 功能,游戏中被减分或者加分就能得到修改分数的代码有四行:

S2

通过观察这四行的调用次数可以知道:

  • 第一行是在分数完全更新后才被调用
  • 第二行只要有分数加减就不停的被调用
  • 第三行各个分数计算结果为负时才调用
  • 第四行各个分数计算结果为正时才调用

这个游戏采用了实时分数计算机制,在同一时刻会有加分也会有减分,计算完分数之后再加到总分数当中,同时加分和减分会在游戏中是这样显示的:

S3

查看每一行汇编代码的地址,然后在IDA中打开这些地址能够发现其中三行代码来自同一个函数,只有第一行代码才来自于单独的函数。

3.分析分数函数(一)

先分析第一行汇编代码的函数,这是一个非常小的一段函数,根据上面的分析重命名函数为 finalScoreUpdate 。此函数通过 __thiscall 方式调用的,接受三个参数一个是this指针,剩下两个是整数变量:

1
int __thiscall finalScoreUpdate(DWORD this, int a2, int a3);

看下面的实现就能知道a2就是最终要显示的分数,因为 this +4 是目前分数板上面的数值。通过 ReClass.NET 查看内存地址猜测每个成员的数值变化可以大概推断作用:

1
2
3
4
5
6
7
8
9
10
int result; // eax

*(_DWORD *)this = a2; //设置目标分数(最终要显示的分数)
result = *(_DWORD *)(this + 8);
*(_DWORD *)(this + 0x18) = 1; //设置状态标志为1(表示分数在更新)
*(_DWORD *)(this + 0x10) = a3; //保存时间或速度参数(可能是过渡之类的)
*(_DWORD *)(this + 0xC) = 0; //重置中间过渡值为0
*(_DWORD *)(this + 4) = result; // 分数完全更新后被调用
*(_DWORD *)(this + 0x14) = result; //保存当前基础分数(可能用于计算差值)
return result;

那么可以确定此函数的作用是当玩家击杀敌人或触发得分变化的时候调用,然后更新参数初始化分数后为后面的平滑过渡做准备,接下来看一下第二个函数。

4.分析分数函数(二)

第二个函数有两个很显眼的字符串分别是 ScoreUpdateNegStopScoreUpdatePosStop ,这两个判断分别对比计算之后的最终分数。最终要给总分数加分的话就调用 ScoreUpdatePosStop 事件,反之就是 ScoreUpdateNegStop 事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if ( v9 >= v8 )
{
if ( v9 > v8 )
{
v12 = (__int64)off_1039044;
v16 = 0x80000012; // 可能是某种错误代码或者状态码
v17 = "ScoreUpdateNegStop";
sub_4BA510((int)&v16, (int)&v12);
sub_820460(&v16);
}
*(_DWORD *)(this + 4) = *(_DWORD *)this;// 各个分数计算结果为负时调用
}
else
{
v13 = (__int64)off_1039044;
v14 = 0x80000012;
v15 = "ScoreUpdatePosStop";
sub_4BA510((int)&v14, (int)&v13);
sub_820460(&v14);
*(_DWORD *)(this + 4) = *(_DWORD *)this;// 各个分数计算结果为正时调用
}
  • 正分数停止:当最终分数超过当前分数时,调用 ScoreUpdateNegStop 判断事件。
  • 负分数停止:当最终分数低于当前分数时,条用 ScoreUpdatePosStop 判断事件。

因此可以把此函数命名为 scoreUpdate 函数,此函数通过 __thiscall 方式调用的,接受两个参数一个是this指针,另一个是整数变量:

1
int __thiscall scoreUpdate(DWORD this, int a2);

在进入正负分数判断之前会有一个调用次数最多的判断,根据 this + 0x18 的状态来进入平滑过渡计算,分数地址的写入是通过 v7 变量赋值进行的。v7 变量是通过函数 sub_5DA6D0 得到的,反过来看这个函数的实现是一个浮点数的计算公式。

根据网上搜索得知此函数实现了一个 二次缓动插值函数 (Quadratic Ease-in-out),主要用在得分系统中实现分数的平滑过渡效果。比如当我们在游戏中执行某个操作失去或者获得分数时,游戏不会立即把分数变成新的分数而是使用这个缓动函数计算中间值创建一个平滑过渡看起来分数加的很自然。

总而言之,在游戏中分数在慢慢递增或者递减的时候就开始不停的调用此函数来进行加分实现过渡动画。那么可以把这个函数命名为 InOutQuad 为了方便理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
v3 = *(_DWORD *)(this + 0x18);
if ( v3 != 1 ) // 检查状态值知否为1,是的话进入平滑过渡计算
{
result = *(_DWORD *)(this + 0x18) - 2;
if ( *(_DWORD *)(this + 0x18) != 2 ) // 再次检查状态是否为2,是的话进入平滑过渡计算
return result;
v5 = *(_DWORD *)(this + 0x14);
v6 = *(float *)(this + 0x10);
v17 = (const char *)(*(_DWORD *)this - v5);
v15 = (const char *)v5;
v11 = (float)(int)v17;
v10 = (float)v5;
v7 = InOutQuad(*(float *)(this + 0xC), v10, v11, v6);// 平滑过渡计算
*(_DWORD *)(this + 4) = _ftol2_sse_excpt(v7);// 只要有分数加减就不停的被调用
}

在函数的后面部分有个 v18 的变量类似于获取了时间增量(也有可能是基于帧时间),这里的特殊数字 1048576 是2的20次方:

1
2
v18 = (double)qword_1224770 / 1048576.0;      // 获取时间增量
*(float *)&v19 = *(float *)(this + 0xC) + v18;

获取到的时间增量值用来更新中间值 this +0xC ,确保不超过目标值 this +0x10

1
2
if ( *(float *)(this + 0x10) <= (double)*(float *)&v19 )// 更新中间值确保不超过目标值
result = *(_DWORD *)(this + 0x10);

那么可以确定此函数的功能为在每帧渲染或者游戏循环中调用,并且用动态方式调整目前显示的分数直到变成目标值。

5.结构体推测(根据偏移量)

1
2
3
4
5
6
7
8
9
struct ScoreController {
int targetScore; // +0x0 (由a2设置的目标分数)
int displayScore; // +0x4 (当前显示的分数)
int previousScore; // +0x8 (更新前的原始分数)
int intermediateValue; // +0xC (用于平滑计算的中间值)
int transitionParam; // +0x10 (由a3设置的过渡参数,如时间/速度)
int baseScore; // +0x14 (初始分数副本)
int state; // +0x18 (状态标志: 1=更新中, 2=结束过渡)
};

6.分数系统工作流程

  1. 得分在进行变化时,会先调用 finalScoreUpdate 设置新的目标分数(a2参数)和一个过渡参数(a3参数),再进行重置中间状态。
  2. 通过调用 scoreUpdate 函数根据时间的增量值 v18 会逐渐更新中间值 this +0xC (intermediateValue)。
  3. 之后使用插值函数(InOutQuad)计算分数的过渡效果,再将结果转换为整数后更新 this + 4 (displayScore)。
  4. 当中间值 this +0xC (intermediateValue)达到目标值时,会将状态设为0,结束过渡过程并锁定 this + 4 (displayScore)到最终值。

7.IDA Pro当中设置结构体

我们推测出了大部分分数相关的结构体,这种情况下可以在IDA中自定义上面推测出来的结构体。打开 Local Types 界面,鼠标右键点击 Add Type ,然后输入结构体名称和大小点击 OK 即可创建一个空的结构体。

S4

在新创建的空结构体上面按快捷键 D 可以选择成员变量类型,根据上面的结构体形式添加好所有变量:

S5

回到我们的 C++ 伪代码当中,在函数声明 this 指针上面鼠标右键选择 Convert to struct * .. 选项,然后选择我们刚刚添加的结构体后发现代码变得顺眼多了。


以下是添加完结构体之后的完整伪代码:

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
int __thiscall scoreUpdate(ScoreController *this, int a2)
{
int state; // edi
int result; // eax
int baseScore; // eax
int transitionParam; // ecx
double v7; // st7
int targetScore; // ecx
int v9; // eax
float v10; // [esp+4h] [ebp-34h]
float v11; // [esp+8h] [ebp-30h]
__int64 v12; // [esp+18h] [ebp-20h] BYREF
__int64 v13; // [esp+20h] [ebp-18h] BYREF
unsigned int v14; // [esp+28h] [ebp-10h] BYREF
const char *v15; // [esp+2Ch] [ebp-Ch]
unsigned int v16; // [esp+30h] [ebp-8h] BYREF
const char *v17; // [esp+34h] [ebp-4h]
float v18; // [esp+40h] [ebp+8h]
int v19; // [esp+40h] [ebp+8h]

state = this->state;
if ( state != 1 ) // 检查状态值知否为1,是的话进入平滑过渡计算
{
result = this->state - 2;
if ( this->state != 2 ) // 再次检查状态是否为2,是的话进入平滑过渡计算
return result;
baseScore = this->baseScore;
transitionParam = this->transitionParam;
v17 = (const char *)(this->targetScore - baseScore);
v15 = (const char *)baseScore;
v11 = (float)(int)v17;
v10 = (float)baseScore;
v7 = InOutQuad(*(float *)&this->intermediateValue, v10, v11, *(float *)&transitionParam);// 平滑过渡计算
this->displayScore = _ftol2_sse_excpt(v7); // 只要有分数加减就不停的被调用
}
if ( *(float *)&this->transitionParam <= (double)*(float *)&this->intermediateValue )
{
if ( state == 1 )
{
sub_58E340(a2);
}
else if ( state == 2 )
{
targetScore = this->targetScore;
this->state = 0;
v9 = this->baseScore;
if ( v9 >= targetScore )
{
if ( v9 > targetScore )
{
v12 = (__int64)off_1039044;
v16 = 0x80000012; // 可能是某种错误代码或者状态码
v17 = "ScoreUpdateNegStop";
sub_4BA510((int)&v16, (int)&v12);
sub_820460(&v16);
}
this->displayScore = this->targetScore; // 各个分数计算结果为负时调用
}
else
{
v13 = (__int64)off_1039044;
v14 = 0x80000012;
v15 = "ScoreUpdatePosStop";
sub_4BA510((int)&v14, (int)&v13);
sub_820460(&v14);
this->displayScore = this->targetScore; // 各个分数计算结果为正时调用
}
}
}
v18 = (double)qword_1224770 / 1048576.0; // 获取时间增量
// 1048576 = 2的20次方
*(float *)&v19 = *(float *)&this->intermediateValue + v18;
result = v19;
if ( *(float *)&this->transitionParam <= (double)*(float *)&v19 )// 更新中间值确保不超过目标值
result = this->transitionParam;
this->intermediateValue = result;
return result;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
int __thiscall finalScoreUpdate(ScoreController *this, int a2, int a3)
{
int result; // eax

this->targetScore = a2; // 设置目标分数(最终要显示的分数)
result = this->previousScore;
this->state = 1; // 设置状态标志为1(表示分数在更新)
this->transitionParam = a3; // 保存时间或速度参数(可能是过渡之类的)
this->intermediateValue = 0; // 重置中间过渡值为0
this->displayScore = result; // 分数完全更新后被调用
this->baseScore = result; // 保存当前基础分数(可能用于计算差值)
return result;
}

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