一、概述

接上一篇文章,这里我们继续实现UE4 ShooterGame的外挂,在这个外挂中,我们会实现FPS游戏常见的自瞄以及透视功能。

这篇文章中,我们会先从源码编译ShooterGame游戏,之后根据UE4的文档了解相关的数据结构,基于这些数据结构获取我们想要的数据,最后基于数据实现我们的自瞄、透视外挂。

首先声明,个人对UE4没有任何研究,最终的外挂实现是基于网上已有的一些UE4外挂代码修改过来的,可能会绕一些弯路,若有不对的地方,欢迎指出。

二、编译ShooterGame

ShooterGame的编译主要就是这两个步骤。

  1. 下载UE4源码,编译UE4引擎。
    1. 在Github上加入EpidGame组织,参考文章
    2. 获取UE4源码。
    3. 根据Readme编译UE4源码。
  2. 下载ShooterGame游戏源码,编译游戏。
    1. 下载Epic Game Store客户端。
    2. 通过Epic Game Store下载ShooterGame源码。
    3. 编译。

2.1 编译UE4源码

UE4引擎有两种版本,一种是已经编译好的安装版,这个可以通过Epic Game Store客户端下载,但安装版的UE4引擎存在一些限制,而且也不方便从源码角度了解UE4的数据结构,因此这里我们不使用这种方案。

首先根据文章将自己的Github账号添加到Epic Game组织中,之后登录自己的Github账号并打开UE4引擎的Github仓库链接,就能看到源码信息。

image-20211205230815956

UE4有多个版本,这里自己使用的是4.22.2版本,没有使用最新的4.27.1(2021.12.05)。原因是自己早都实现外挂了,只是现在才写文章,最新版本的UE4源码与4.22.2差异有点大,需要重新适配,这里自己懒得重新研究适配了。

编译UE4引擎的流程在仓库的Readme中已经说的很详细了,按照这个步骤一步步执行即可。这里说明一点,在文档中指定用visual studio 2017编译源码,但经过自己测试用visual studio 2019也是可以的。另外,自己第一次使用2019编译源码时出现了一些错误,基于“经验”怀疑是编译器的bug,在更新了visual studio之后,代码果然顺利编译通过。如果你在编译过程中也遇到了一些问题,那么也可以尝试下升级编译器。

image-20211205231020958

编译UE4引擎可能需要花一些时间,具体多久取决于你电脑的配置。另外建议将源码放在SSD硬盘中,也能一定程度的加快编译速度。

2.2 编译ShooterGame游戏

在编译UE4引擎期间,同时打开Epic Games Store客户端,下载ShooterGame的源码。在Epic Games Store中,依次进入 虚幻引擎->学习->射击游戏。

image-20211205232141928

之后创建工程,在创建工程时,注意UE4的版本需要与我们编译的UE4版本相一致,否则代码估计会编译不过。

image-20211205232336850

创建之后,Epic Game客户端将会开始在指定的路径中下载代码。

在UE4引擎编译完毕以及ShooterGame代码下载好之后,进入ShooterGame的源码文件夹,在.uproject文件上右键,选择Switch Unreal Engine Version,之后选择我们编译好的4.22.2版本。之后将会生成ShooterGame的visual studio工程.sln文件,之后使用visual studio打开工程文件。

打开之后,解决方案配置选择Development Editor,解决方案平台选择Win64,启动项目选择ShooterGame,之后按下Ctrl + F5编译运行。如果不出意外,将会弹出UE4编辑器。

image-20211205234213463

在编辑器中设置编译配置信息,依次选择 文件->打包项目->Build Configuration->发布客户端。

image-20211205235315983

之后开始编译生成游戏客户端,依次选择文件->打包项目->窗口->Windows(64),之后会弹出一个框,用于指定编译后的客户端生成路径,选择之后编辑器将开始编译游戏客户端,这里依旧需要花费一段时间,具体取决于你电脑的配置了。

image-20211205235541578

一般来说,只要游戏版本与UE4引擎的版本相一致,那么就不会出现编译上的错误。当编译完成之后,游戏将会生成在指定路径下的WindowsClient的文件夹中,这个文件夹是可以随意拷贝的,游戏依赖的所有文件都已经在这个文件夹下了,直接执行文件夹下的ShooterGame.exe便能够启动游戏。

三、实现

前面的游戏编译只是准备工作,接下来开始进入正题。

我们的目标是实现ShooterGame的自瞄和透视外挂,在实现之前我们需要先明白自瞄和透视外挂的基本原理。

所谓自瞄外挂,就是可以帮助我们自动瞄准敌人的程序,既然要自动瞄准,按照常理来想,我们肯定要知道敌人的位置,然后也知道自己的朝向位置,之后把自己的朝向设置到敌人的位置上。

而透视,则是可以显示那些我们本看不到的敌人的位置,因此透视外挂需要获取到敌人的位置信息,然后再将其在画面中渲染出来。

在上一篇文章中,我们提到,外挂的实现一般都按照输入、数据处理、输出这3个步骤实现,这个外挂也不例外。

对于这个外挂来说,输入便是敌人的坐标以及自己的朝向信息。数据处理则是一些坐标换算,比如如何将敌人的位置信息换算成敌人的桌面坐标信息等。而输出则是设置朝向以及渲染敌人的位置。

在获取游戏的输入数据之前,我们先了解一下ShooterGame游戏中位置信息和朝向的基本知识。

3.1 基本知识

ShooterGame是一个三维的游戏,因此要在这个三维世界中表示玩家的位置就需要XYZ三个坐标轴表示,ShooterGame的坐标轴如下。XY坐标轴所在的平面即游戏中的地面,而玩家的高度则使用Z轴表示。

image-20220115171605363

一般在FPS游戏中,朝向使用欧拉角,即pitch、yaw以及roll这三个变量表示,其中,pitch表示上下的摆动的角度,yaw表示左右旋转的角度,而roll则表示左右摇晃的角度。在ShooterGame这个游戏中,roll变量并未用到,一直是0。

img

接下来,我们一步步分析,首先先看输入部分,如何获取敌人的位置和自己的朝向信息。

3.2 获取输入

3.2.1 游戏结构

一般来说,所有人物的位置信息以及玩家的朝向信息在游戏内存中是存储着的。为了能够拿到这些信息,外挂开发者会借助上一篇文章中提到的CheatEngine以及反汇编逆向分析等手段分析游戏的代码逻辑,从而拿到这些信息。

而ShooterGame的代码是开源的,UE4的代码也是开源的,因此我们只要了解了UE4的一些基本概念之后,便可以很轻松的拿到这些信息。

通过虚幻引擎4术语文档以及《Inside UE》系列文章,我们可以基本了解UE4的大体框架结构如下。

image-20220131160331858

其中,

  • UGameInstance类,对应UE中最顶层的Game概念,在游戏创建时产生,直到游戏实例关闭时才被销毁,它通过WorldContext(图中未表现)管理着游戏中的多个世界(World)。一般来说,全局唯一。
  • UWorld类,对应引擎中的世界(World)概念,它包含了游戏中的所有关卡(Level)。它可以处理关卡流送,还能生成(创建)动态Actor。
  • ULevel类,对应引擎中的**关卡(Level)**概念,它是用户定义的游戏区域。关卡包含了玩家能看到的所有内容,例如几何体、Pawn和Actor。
  • AActor类,对应Actor概念,所有可以放入关卡的对象都是 Actor,无论是实体还是非实体,比如摄像机、静态网格体。
  • USceneComponent类,AActor本身不包含位置信息,UE将其封装到了UScaneComponent中,作为AActorRootComponent成员。
  • APawn类,对应Pawn概念,它是Actor的子类,可以与玩家发生交互的称之为Pawn,它可以由玩家操控,也可以由游戏AI控制并以非玩家角色(NPC)的形式存在于游戏中。
  • AShooterCharacter类,该类是ShooterGame游戏中的类,非UE引擎中定义的类。该类对应**角色(Character)**概念,它是ACharacter的子类(图中未展示),而ACharacter又是APawn的子类。ACharacter旨在表示所有的人形的带骨骼的Pawn。
  • APlayerController类,对应**玩家控制器(Player Controller)**概念,它会获取游戏中玩家的输入信息,然后转换为交互效果,每个游戏中至少有一个玩家控制器。玩家控制器通常会控制一个Pawn或Character,将其作为玩家在游戏中的化身。
  • APlayerState类,存储Player的一些状态信息。

前面提到,我们需要获取的有:

  1. 自己的坐标信息。
  2. 自己的朝向信息。
  3. 敌人的坐标信息。

基于UE4引擎的框架图,我们知道,坐标信息存储在AActor类的RootComponent成员的RelativeLocation成员中。

我们控制的玩家以及所有的敌人都是APawn,我们自己控制的APawn可以通过APlayerController中的AcknowledgedPawn成员拿到。而敌人的APawn则通过遍历Level中的所有Actor对象并过滤得到。

而自己的朝向信息则存储在APlayerController类的ControlRotation成员中。

那接下来问题就是,如何基于上面的理论获取坐标和朝向信息了。

3.2.2 实现

坐标和朝向信息都存储在游戏进程的内存中,只要有这些信息的地址,我们就可以拿到数据。那么如何拿到这些信息的地址呢?

在UE引擎中,全局定义了UWorldProxy对象,可以通过它拿到全局的UWorld对象,再基于UWorld对象,就可以拿到ULevel对象,最后再由ULevele对象的Actors成员数组拿到所有的Actor对象。

同样,我们也可以通过UWorld对象的OwningGameInstance成员拿到全局的UGameInstance对象,再基于UGameIntance对象获取到APlayerController对象,最后获取到APlayerController对象中的朝向信息。

那么这里的关键就是如何获取UWorldProxy对象的地址了。由于这个游戏是我们自己编译的,所以,我们可以很容易的通过编译时生成的PDB文件获取到全局UWorldProxy对象的地址。

这种方式其实是作弊了😢,对于实际的游戏来说,我们根本不可能拿到PDB文件。因此,我们需要像上一篇文章那样使用CheatEngine或是其他的X64DBG、IDA等逆向分析工具对游戏进行逆向分析得到关键的地址信息。不过,由于我们文章的目标是展示外挂的基本实现原理,逆向分析只是获取地址的手段,因此这里也就不再使用那种方式了(主要是我也不熟🤷‍♂️)。

除了关键的UWorldProxy对象基址外,我们还需要知道各个类的定义,这样才能根据基址拿到其成员变量的值。虽然我们有各个类定义的代码,但他们继承关系复杂,把他们抠出来还是要花费一番功夫的,因此这里我们直接使用基址+成员变量偏移的方式获取成员变量的数据,而偏移的信息依旧可以通过PDB获取(如何通过PDB获取偏移地址可以参考文章PDB文件解析)。

在上一篇文章中,我们使用了跨进程读内存的方法获取游戏棋盘的数据。而在这个外挂中,我们需要调用一些游戏的画图函数绘制方框,因此将我们的外挂实现为游戏进程的一个dll。

通过PDB解析到的基址和偏移信息通过宏进行表示,下面是一个示例。注意这只是我编译出的游戏的偏移,在你那里是不适用的。

 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
#ifndef _SHOOTERCLIENT_WIN64_SHIPPING_DEF_H_
#define _SHOOTERCLIENT_WIN64_SHIPPING_DEF_H_

#define GAME_GWORLD                                         (0x2F70070)
#define GAME_GENGINE                                        (0x2F6D6F8)
#define GAME_NAMES_PTR                                      (0x2E6D0F8)
#define GAME_PERSISTENT_LEVEL_OFFSET                        (0x30)
#define GAME_GAMEINSTANCE_OFFSET                            (0x160)
#define GAME_LOCALPLAYER_OFFSET                             (0x38)
#define GAME_PLAYERCONTROLLER_OFFSET                        (0x30)
#define GAME_ALL_ACTORS_OFFSET                              (0x98)
#define GAME_OBJ_NAME_OFFSET                                (0x18)
#define GAME_MEDIUM_FONT                                    (0x70)
#define GAME_CANVAS_OFFSET                                  (0x380)
#define GAME_ACKNOWLEDEGED_PAWN_OFFSET                      (0x3B0)
#define GAME_CONTROLROTATION_OFFSET                         (0x398)
#define GAME_PLAYERCONTROLLER_HUD_OFFSET                    (0x3C0)
#define GAME_PAWN_PLAYERSTATE_OFFSET                        (0x350)
#define GAME_PAWN_HEALTH_OFFSET                             (0x92C)
#define GAME_PAWN_NAME                                      (0x438)
#define GAME_PAWN_ROOTCOMMENT                               (0x158)
#define GAME_PAWN_LOCATION                                  (0x164)
#define GAME_GET_CAMERADAMAGESTARTLOCATION_OFFSET           (0x524080)
#define GAME_WORLD_TO_SCREEN_OFFSET                         (0x1542850)
#define GAME_LINEOFSIGHTTO_OFFSET                           (0x148B970)
#define GAME_GET_VIEWPORT_SIZE                              (0x175BD00)
#define GAME_DRAWTEXT_OFFSET                                (0x155E920)
#define GAME_DRAW_HUD_OFFSET                                (0x155E280)

#endif //_SHOOTERCLIENT_WIN64_SHIPPING_DEF_H_

获取当前玩家控制的pawn对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
uint8_t* get_local_pawn(uint8_t** world)
{
    if (!*world)
    {
        return nullptr;
    }

    uint8_t *owning_game_instance = *(uint8_t**)(*world + GAME_GAMEINSTANCE_OFFSET);
    uint8_t* local_players = *(uint8_t**)(owning_game_instance + GAME_LOCALPLAYER_OFFSET);
    uint8_t* local_player = *(uint8_t**)(local_players + 0);
    uint8_t *player_controller = *(uint8_t**)(local_player + GAME_PLAYERCONTROLLER_OFFSET);
    uint8_t *local_pawn = *(uint8_t**)(player_controller + GAME_ACKNOWLEDEGED_PAWN_OFFSET);
    return local_pawn;
}

获取关卡中所有的actor,并过滤出其他pawn的代码。

 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
std::string get_object_name(uint8_t *obj)
{
    FName *f_name = (FName*)(obj + GAME_OBJ_NAME_OFFSET);
    if (IsBadReadPtr(f_name, sizeof(FName)))
    {
        return "";
    }

    std::string name(f_name->GetName());
    if (f_name->Number > 0)
    {
        name += '_' + std::to_string(f_name->Number);
    }

    auto pos = name.rfind('/');
    if (pos == std::string::npos)
    {
        return name;
    }

    return name.substr(pos + 1);
}

std::vector<uint8_t*> get_other_pawns(uint8_t** world)
{
    uint8_t* player_controller = get_local_player_controller(g_world);
    uint8_t *persistent_level = *(uint8_t**)(*world + GAME_PERSISTENT_LEVEL_OFFSET);
    
    uint8_t *local_pawn = *(uint8_t**)(player_controller + GAME_ACKNOWLEDEGED_PAWN_OFFSET);
    uint8_t *actors = (uint8_t*)(persistent_level + GAME_ALL_ACTORS_OFFSET);
    TArray<uint8_t*> actor_array = *(TArray<uint8_t*>*)actors;

    std::vector<uint8_t*> pawns;
    for (size_t i = 0; i < actor_array.Num(); i++)
    {
        uint8_t* actor = actor_array[i];
        if (actor == nullptr)
        {
            continue;
        }

        std::string name = get_object_name(actor);
        if (name.find("awn_C_") == std::string::npos)
        {
            continue;
        }

        int health = (int)get_pawn_health(actor);
        if (actor == local_pawn || health < 0)
        {
            continue;
        }

        pawns.push_back(actor);
    }

    return pawns;
}

获取pawn的位置信息。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
bool get_pawn_location(uint8_t *pawn, FVector *location)
{
    uint8_t *root_commoent = *(uint8_t**)(pawn + GAME_PAWN_ROOTCOMMENT);
    if (root_commoent)
    {
        FVector relative_location = *(FVector*)(root_commoent + GAME_PAWN_LOCATION);
        location->X = relative_location.X;
        location->Y = relative_location.Y;
        location->Z = relative_location.Z;
        return true;
    }

    return false;
}

获取当前玩家的朝向信息。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void get_local_player_rotation(uint8_t **world, float *pitch, float *yaw)
{
    uint8_t *owning_game_instance = *(uint8_t**)(*world + GAME_GAMEINSTANCE_OFFSET);
    uint8_t *local_players = *(uint8_t**)(owning_game_instance + GAME_LOCALPLAYER_OFFSET);
    uint8_t *local_player = *(uint8_t**)(local_players + 0);
    uint8_t *player_controller = *(uint8_t**)(local_player + GAME_PLAYERCONTROLLER_OFFSET);

    *pitch = *(float*)(player_controller + GAME_CONTROLROTATION_OFFSET);
    *yaw = *(float*)(player_controller + GAME_CONTROLROTATION_OFFSET + 4);
}

3.3 数据处理

获取到基本信息,之后就需要对数据进行处理了。

3.3.1 透视

这里先考虑实现透视功能,透视功能主要是把不可见的敌人位置在游戏画面中展示出来。敌人的位置信息我们已经拿到了,那接下来问题就是如何将敌人的位置信息转换成屏幕的坐标信息?

下图中,虚线所在的方框可以理解为我们的屏幕,实线的红色小人是敌人,虚线的红色小人是敌人在屏幕上的投影,而蓝色小人则是我们自己。这里我们建立一个视图坐标系,以我们自己所在地方为原点,我们所看向的中心点为Z轴的正方向,右方为X轴正方向,上方为Y轴的正方向。

假如我们知道敌人在这个坐标系中的坐标(X,Y,Z)、屏幕的宽和高(width, height)以及我们的视野夹角(FOV),那么我们通过等比计算就能很容易的得到敌人在屏幕上的位置。

image-20220115165039442

这里推导了物体在屏幕上的X’坐标,Y坐标同理。

1
2
3
4
x / x' = z / depth  
==>   x' = x * depth / z
==>   depth = width / 2 / tan(FOV / 2);
==>   x' = x * (width / 2 / tan(FOV / 2)) / z

image-20220115182234689

注意,这里得到的X’的值是在我们视图坐标系中屏幕上的坐标值,但实际的屏幕坐标表示并不以视图坐标系表示,而是以屏幕的左上角为原点,以原点向右为X正方向,以原点向下为Y轴正方向。所以,为了得到最终的屏幕坐标,还需要稍微调整下。

相应的代码如下。

1
2
3
4
int ScreenCenterX = width / 2;
int ScreenCenterY = height / 2;
ScreenX = ScreenCenterX + X * (ScreenCenterX / tan(FOV / 2)) / Z;
ScreenY = ScreenCenterY - Y * (ScreenCenterY / tan(FOV / 2)) / Z;

以上的计算结果的前提都是基于视图坐标系的,但实际上,我们自己以及敌人都是在世界坐标系中的,因此,为了方便计算,需要将我们和敌人的世界坐标都转为视图坐标。

下图中(画的不是很好),红色的XYZ坐标系是世界坐标系,而蓝色的CameraXYZ则是视图坐标系,其中CameraZ与我们的朝向一致。由于在游戏中并没有使用Roll角,因此CameraX始终在XY平面上,其z变量恒为0。而CameraY则与CameraX、CameraZ互相垂直。

image-20220131163300297

现在我们需要先在世界坐标系中表示出我们的视图坐标系,之后任意一个世界坐标与视图坐标系相乘后,将会得到世界坐标在视图坐标中的映射。

我们先来看下CameraZ轴,CameraZ轴是我们的朝向,因此其与XY平面的夹角是pitch,其在XY屏幕上的投影向量OB与X轴的夹角是yaw。

假设CameraZ轴上有一点A,原点到点A的距离是1(即单位向量),点A在XY平面上的投影是点B,基于三角函数,我们可以得到线段AB的长度是sin(pitch),同样我们可以得到线段OB的距离是cos(pitch),B点与X轴垂直相交于点C,那么线段BC的长度是OB * sin(yaw),即cos(pitch) * sin(yaw),而线段OC的长度则是OB * cos(yaw),即cos(ptich) * cos(yaw)。因此,单位向量OA在世界坐标中的表示如下。

1
2
3
X = OC = cos(pitch) * cos(yaw)
Y = BC = cos(pitch) * sin(yaw)
Z = AB = sin(pitch)

继续来看CameraX轴,CameraX轴就在平面XY中,由于其与CameraZ轴垂直,因此我们也可以得出其与CameraZ在XY平面上的OB向量垂直,由于OB与X轴正方向的夹角是yaw,因此OD与Y轴正方向的夹角也是yaw。

假设在CameraX轴上有一点D,其中OD的长度是1(即单位向量),OD与Y轴垂直于E点,那么我们可以计算出线段OE的长度是cos(yaw),而线段DE的长度是sin(yaw)。因此,单位向量OD在世界坐标中的表示如下。

1
2
3
X = ED = -sin(yaw)
Y = OE = cos(yaw)
Z = 0

再来看CameraY轴,CameraY轴与CameraZ和CameraX轴组成的平面垂直,再由于CameraZ轴与XY平面的夹角是pitch,因此我们可以得出CameraY与XY平面的夹角是90°-pitch。同时由于CameraY与CameraZ在同一个平面上,因此CameraY在XY平面上的投影向量与X轴的负方向的夹角是yaw(图中线段GB是一条直线,OB与X轴正方向的夹角是yaw,对角相等,因此OG与X轴负方向的夹角也是yaw)。

假设CameraY轴上有一点F,OF的长度是1,点F投影在XY平面的是G点,由于OF与XY平面的夹角是90° - pitch,因此FG与FO的夹角就是pitch,因此OG的长度是sin(pitch),GF的长度是cos(pitch),OG与X轴垂直于H点,由于OG与OH的夹角是yaw,因此线段OH的长度是OG * cos(yaw),即 sin(pitch) * cos(yaw), 线段GH的长度是OG * sin(yaw),即sin(pitch) * sin(yaw)。由此,单位向量OF在世界坐标中的表示是。

1
2
3
X = OH = -sin(pitch) * cos(yaw)
Y = HG = -sin(pitch) * sin(yaw)
Z = GF = cos(pitch)

现在假设有一点,在世界坐标系中的坐标是(X,Y,Z),我们的位置在(X',Y',Z'),且我们的朝向是是(pitch,yaw,0)。

那么这个点在视图坐标系中的坐标便是: $$ \left[ \begin{matrix} -sin(yaw) & cos(yaw) & 0 \\
-sin(pitch) * cos(yaw) & -sin(pitch) * sin(yaw) & cos(pitch) \\
cos(pitch)*cos(yaw) & cos(pitch) * sin(yaw) & sin(pitch) \\
\end{matrix} \right] · \left[ \begin{matrix} X - X' \\
Y - Y' \\
Z - Z' \\
\end{matrix} \right] $$

拿到了该点在视图坐标中的坐标后,基于前面的步骤便可以得出其在屏幕上的坐标值了。

相应的代码实现如下。

 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
FVector matrix_transform(FVector target_pos, FVector rotation, FVector camera_pos)
{

    float rad_pitch = (rotation.X * float(PI) / 180.f);
    float rad_yaw = (rotation.Y * float(PI) / 180.f);
    float rad_roll = (rotation.Z * float(PI) / 180.f);

    float sp = sinf(rad_pitch);
    float cp = cosf(rad_pitch);
    float sy = sinf(rad_yaw);
    float cy = cosf(rad_yaw);

    FVector axis_x, axis_y, axis_z;

    axis_x = FVector(-sy, cy, 0);
    axis_y = FVector(-sp * cy, -sp * sy, cp);
    axis_z = FVector(cp * cy, cp * sy, sp);

    FVector delta = target_pos - camera_pos;
    FVector transformed = FVector(delta.Dot(axis_x), delta.Dot(axis_y), delta.Dot(axis_z));
    return transformed;
}

bool world_to_screen(uint8_t *player_controller, FVector target_pos, FVector2D *screen_pos)
{
    int window_width, window_height;
    g_get_viewport_size_func((uint8_t *)player_controller, &window_width, &window_height);

    uint8_t *camera_mgr = *(uint8_t **)(player_controller + GAME_PLAYER_CAMERA_MANAGER);
    FVector camera_angle = *(FVector *)(camera_mgr + GAME_CAMERACACHEPRIVATE + GAME_CAMERA_POV + GAME_CAMERA_ROTATION);
    FVector camera_location = *(FVector *)(camera_mgr + GAME_CAMERACACHEPRIVATE + GAME_CAMERA_POV + GAME_CAMERA_LOCATION);
    float camera_fov = *(float *)(camera_mgr + GAME_CAMERACACHEPRIVATE + GAME_CAMERA_POV + GAME_CAMERA_FOV);

    FVector transformed = matrix_transform(target_pos, camera_angle, camera_location);

    if (transformed.Z < 0.f)
    {
        // 说明在背后,就没必要显示了
        return false;
    }

    float screen_center_x = window_width / 2.0f;
    float screen_center_y = window_height / 2.0f;

    float tmp_fov = tanf(camera_fov * (float)PI / 360.f);
    screen_pos->X = screen_center_x + transformed.X * (screen_center_x / tmp_fov) / transformed.Z;
    screen_pos->Y = screen_center_y - transformed.Y * (screen_center_x / tmp_fov) / transformed.Z;

    return true;
}

计算屏幕坐标的逻辑比较复杂,自己也是研究了好长时间才明白的(主要是矩阵、三角函数之类的知识长期不用都忘了),其实对于我们写外挂来说,需要的主要就是世界坐标转换为屏幕坐标的功能,除了上面我们实现的world_to_screen方法外,UE4已经给我们提供了类似的函数,即ProjectWorldToScreen函数,其定义如下。

1
2
3
4
5
6
static bool UGameplayStatics::ProjectWorldToScreen(
    APlayerController const* Player, 		// 当前玩家的PlayerController
    const FVector& WorldPosition,			// 世界中的位置信息,即敌人的位置信息
    FVector2D& ScreenPosition, 				// 转换后的屏幕坐标信息
    bool bPlayerViewportRelative = false	// 是否是相对于玩家视口子区域的,默认为假
); 

其中,PlayerController以及WorldPosition参数我们都已经知道了,那么直接调用这个函数就可以得到敌人在屏幕上的坐标信息了。相较于上面长篇大论的理论来说,要简单的多😁。

拿到屏幕坐标信息后,直接在屏幕上将敌人绘制出来即可,这个在后面的输出部分说明。

3.3.2 自瞄

自瞄功能的数据主要分为2步。

  1. 选出合适的敌人作为自动瞄准的对象。
  2. 基于自己和敌人的位置信息计算出朝向值。

所有敌人的位置信息已经有了,那么哪个敌人应该作为自动瞄准的对象呢?一般来说,比较符合直觉的习惯是选择离我们准星最近的敌人作为瞄准的对象,所以,第一步就需要我们先找到这个离我们准星最近的敌人。

image-20220115175054042

我们的准星其实就是游戏画面的中心,获取所有敌人的屏幕坐标位置,然后计算敌人屏幕位置与屏幕中心位置的距离即可。对于那些在我们背后,不在我们画面中的敌人来说,直接忽略就行了。

 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
uint8_t *get_closest_visible_player(uint8_t **world)
{
    std::vector<uint8_t *> actors = get_other_pawns(world);
    uint8_t *player_controller = get_local_player_controller(world);

    int screen_size_x, screen_size_y;
    g_get_viewport_size_func((uint8_t *)player_controller, &screen_size_x, &screen_size_y);

    FVector2D center_screen{(float)screen_size_x / 2, (float)screen_size_y / 2};

    uint8_t *closest_player = nullptr;
    float distance = 300.0f;
    for (auto &candidate : actors)
    {
        FVector pawn_pos;
        get_pawn_location((uint8_t *)candidate, &pawn_pos);

        FVector2D screen_pos;
        if (world_to_screen(player_controller, pawn_pos, &screen_pos))
        {
            float tmp_distance = Utils::get_distance_2d(center_screen, screen_pos);
            if (tmp_distance < 24.0f)
            {
                closest_player = candidate;
                break;
            }
            else if (tmp_distance < distance)
            {
                closest_player = candidate;
                distance = tmp_distance;
            }
        }
    }

    return closest_player;
}

得到离我们准星最近的敌人后,接下来就是计算瞄准该敌人时需要的角度信息。

基于基础知识,我们知道朝向由pitch和yaw这两个角度表示,其中pitch的范围是180°,在不同游戏中的表现方法不同,有的游戏使用-90° ~ +90°的方式表示,有的游戏使用0° ~ 180°的方式表示,而在ShooterGame中,则是使用了270° ~ 90°的方式表示。而yaw的范围则一般是使用0° ~ 360°的方式表示,ShooterGame中也是如此。

image-20220115173619929

在上图中,蓝色小人我们自己,而红色小人则是敌人。我们自己当前的朝向是红线指向的地方,而蓝线则是朝向对准敌人的地方,如果要实现自瞄,那么就需要将我们的朝向设置成蓝线的状态。

这里我们首先计算蓝线的pitch值,pitch值简单来说就是我们目标朝向相对于X ~ Y平面的角度。在下图中,蓝线是我们对敌人朝向的在X ~ Y平面上的映射,而红线则是对敌人的朝向。注意,这里为了统一,两条直线的起点都为蓝色小人的脚底。

image-20220115182302332

我们要计算的pitch值是朝向相对于XY平面的角度,那么在图中便是a角。假设敌人与我们在坐标X轴上的差值是dx,在坐标Y轴上的差值是dy,在坐标Z轴上的差值是dz。那么a角角度的计算方式如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
float dx = target_pos.X - local_pos.X;
float dy = target_pos.Y - local_pos.Y;
float dz = target_pos.Z - local_pos.Z;

const float pi_2 = 1.5707963f;
const float pitch_range = 90.0;

// atan2(dz, sqrt(dx * dx + dy * dy))得到a角的弧度
// 之后再乘以 (pitch_range / pi_2) 得到a角的角度
pitch = atan2(dz, sqrt(dx * dx + dy * dy)) * (pitch_range / pi_2);

// 上述方法计算出来的pitch范围是-90~+90,需要加上360°,使其符合范围要求
if (pitch < 0.0f)
{
    pitch += 360.0;
}

接下来我们计算yaw值,yaw值简单来说就是以X轴为起点的绕Z轴的角度。在下图中,蓝线是对敌人朝向的在X ~ Y平面上的映射,红线是指向X轴正方向的一条线,而我们要求的yaw值便是图中的b角。

image-20220115174942163

我们依旧假设敌人与我们在X轴上的差值是dx,在Y轴上的差值是dy,那么计算yaw值的代码如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
float dx = target_pos.X - local_pos.X;
float dy = target_pos.Y - local_pos.Y;
float dz = target_pos.Z - local_pos.Z;

const float pi_2 = 1.5707963f;
const float yaw_range = 360.0;

// atan2(dy, dx) * yaw_range得到以x轴正方向为起始的绕z轴的弧度
// 再除以 4 / pi_2 得到相应的角度
yaw = atan2(dy, dx) * yaw_range / 4 / pi_2;

// 在游戏中,yaw角度一直为正值,因此这里加个判断,如果是负的,那么就转成正值
if (yaw < 0.0f)
{
    yaw += 360.0;
}

到了这里,我们便拿到了朝向敌人的朝向值。

3.4 实现输出

经过上一步的数据处理,现在我们已经拿到了我们想要得到的数据了,接下来就是将这些数据展示出来。

对于透视来说,我们需要通过画框等手段将敌人的位置显示出来,而对于自瞄来说,我们需要设置我们的朝向为离我们准星最近的敌人。

3.4.1 透视

这里依旧先说透视,常见的透视外挂都是方框透视,即把敌人的位置信息通过方框的形式显示出来,这里我们也采用相同的方式。画框的方式有多种,比如GDI画框,或是DirectX画框,而UE4中也提供了绘制相关的函数,因此这里我们直接使用UE4的函数。

这里的方框实际上是个矩形,UE4提供了绘制矩形的函数AHUD::DrawRect,但这个矩形是实心的,我们想要的是空心的矩形,因此这里我们通过画4条直线的方式模拟一个矩形,绘制直线的方法定义如下。

1
2
3
4
5
6
void AHUD::DrawLine(float StartScreenX, 		// 直线起始X坐标
                    float StartScreenY, 		// 直线起始Y坐标
                    float EndScreenX,			// 直线结束X坐标
                    float EndScreenY,			// 直线结束Y坐标
                    FLinearColor LineColor,		// 颜色
                    float LineThickness)		// 宽度

函数的地址依旧通过PDB解析得到,之后直接调用这个函数就行了。

这里的一个重点是,为了有三维的效果,方框需要遵循近大远小的原则,那么该如何基于敌人的距离计算方框的宽度和高度呢?我们继续看透视部分的这张屏幕投影图。

image-20220115181048786

在这张图里,我们通过等比变换得到了点A在屏幕上的坐标,假设敌人在A点的宽度是W,同样基于等比变换,我们就可以得到其在屏幕上的W',高度同理。

1
2
W / z = W' / depth
H / z = H' / depth

其中z值和depth值我们在实现world_to_screen函数时已经拿到了,这里只差W和H值了。这两个值我是结合游戏中人物的移动坐标的变化进行估计的,其中W是100,H是300,在最终实现的效果来看,结果还可以。

得到了W’和H',再结合上面的画框函数,我们就能够完美的画出人物方框了。

3.4.2 自瞄

相较于透视,自瞄的输出逻辑更加简单直接一些,我们直接将计算出的朝向设置成我们当前的朝向即可。计算出的朝向以及要设置的朝向偏移我们是都已经知道了的。

不过这里也有一个问题,何时触发自瞄这个操作?常见的外挂一般在点击鼠标右键时开启自瞄功能,这里我们也采用这种形式。相应的代码实现如下。

 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

void aimbot()
{
    static uint8_t *target_player = nullptr;

    uint8_t *player_controller = get_local_player_controller(g_world);
    uint8_t *acknowledged_pawn = *(uint8_t **)(player_controller + GAME_ACKNOWLEDEGED_PAWN_OFFSET);
    if (player_controller == nullptr || acknowledged_pawn == nullptr)
    {
        return;
    }

    if (GetAsyncKeyState(VK_RBUTTON) & 0x8000)
    {
        if (target_player == nullptr)
        {
            target_player = get_closest_visible_player(g_world);
        }

        float pitch = *(float *)(player_controller + GAME_CONTROLROTATION_OFFSET);
        float yaw = *(float *)(player_controller + GAME_CONTROLROTATION_OFFSET + 4);
    }
    else
    {
        target_player = nullptr;
    }

    if (target_player != nullptr)
    {
        FVector local_pos, target_pos;
        float pitch, yaw;

        get_local_player_location((uint8_t *)player_controller, &local_pos);
        get_pawn_location((uint8_t *)target_player, &target_pos);

        cal_new_rotation(local_pos, target_pos, pitch, yaw);
        set_local_player_rotation((uint8_t *)player_controller, pitch, yaw);
    }
}

3.5 执行时机

自瞄透视外挂通过读取游戏中玩家与敌人的坐标数据即可实现,为了尽可能的避免对游戏进程产生影响,一般通过进程外的方式实现,但我们这个外挂中,调用了一些游戏自己的函数,因此只能实现在游戏进程内,其中绘制相关的函数还需要游戏中的hud变量,因此我们这个外挂需要通过hook的方式得到相应的指针变量,这个hook的时机便是游戏绘制HUD对象的时机。

这里我们使用detours hook库,hook点是DrawHUD方法。简单代码实现如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 这是我们的钩子函数
void __fastcall draw_hud_detour(void *this_)
{
    uint8_t *canvas = *(uint8_t **)((uint8_t *)this_ + GAME_CANVAS_OFFSET);
    if (canvas)
    {
        // 这里调用我们的透视函数
        esp((uint8_t *)this_);
    }

    g_draw_hud_func(this_);
}

// 这里hook DrawHud 函数
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourAttach(&(void *&)g_draw_hud_func, draw_hud_detour);
DetourTransactionCommit();

3.6 效果

有了执行时机,自瞄和透视的输入、处理、输出步骤也都实现了,基本上一个外挂也就实现了,下面是外挂的效果。

完整的代码参见GitHub链接

四、小结

在这一部分,我们基于UE4的ShooterGame实现了一个自瞄透视外挂,相较于上一篇文章中介绍的连连看外挂,这个自瞄透视外挂要复杂的多(主要是需要的数学知识更多了),但也要有意思的多。其实这个外挂还可以继续完善,还可以实现其他更加强大的功能(比如子弹跟踪功能、显示玩家血量名称等),反正游戏的代码都在你的手里,想怎么折腾就怎么折腾。

通过这两篇文章,可以发现其实一个外挂的实现还是比较简单的,只要你能够拿到游戏中想要的数据,然后做一些的数据处理和输出就可以了。对于游戏开发人员来说,简直不要太简单。但难点也在于此,如何才能拿到我们想要的数据?这个就需要通过逆向分析游戏的代码了,有兴趣的可以去了解下。我对这些逆向分析不是很熟,这里也就不做过多说明了。

在下一篇文章中,我们基于这两个外挂实现的过程,尝试站在游戏开发者的角度,思考如何对抗外挂。

五、参考资料

  1. LearnOpenGL 变换
  2. LearnOpenGL 坐标系统
  3. LearnOpenGL 摄像机
  4. ShooterGameHack