游戏外挂实现简介2——ShooterGame外挂实现
文章目录
一、概述
接上一篇文章,这里我们继续实现UE4 ShooterGame的外挂,在这个外挂中,我们会实现FPS游戏常见的自瞄以及透视功能。
这篇文章中,我们会先从源码编译ShooterGame游戏,之后根据UE4的文档了解相关的数据结构,基于这些数据结构获取我们想要的数据,最后基于数据实现我们的自瞄、透视外挂。
首先声明,个人对UE4没有任何研究,最终的外挂实现是基于网上已有的一些UE4外挂代码修改过来的,可能会绕一些弯路,若有不对的地方,欢迎指出。
二、编译ShooterGame
ShooterGame的编译主要就是这两个步骤。
- 下载UE4源码,编译UE4引擎。
- 在Github上加入EpidGame组织,参考文章。
- 获取UE4源码。
- 根据Readme编译UE4源码。
- 下载ShooterGame游戏源码,编译游戏。
- 下载Epic Game Store客户端。
- 通过Epic Game Store下载ShooterGame源码。
- 编译。
2.1 编译UE4源码
UE4引擎有两种版本,一种是已经编译好的安装版,这个可以通过Epic Game Store客户端下载,但安装版的UE4引擎存在一些限制,而且也不方便从源码角度了解UE4的数据结构,因此这里我们不使用这种方案。
首先根据文章将自己的Github账号添加到Epic Game组织中,之后登录自己的Github账号并打开UE4引擎的Github仓库链接,就能看到源码信息。
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之后,代码果然顺利编译通过。如果你在编译过程中也遇到了一些问题,那么也可以尝试下升级编译器。
编译UE4引擎可能需要花一些时间,具体多久取决于你电脑的配置。另外建议将源码放在SSD硬盘中,也能一定程度的加快编译速度。
2.2 编译ShooterGame游戏
在编译UE4引擎期间,同时打开Epic Games Store客户端,下载ShooterGame的源码。在Epic Games Store中,依次进入 虚幻引擎->学习->射击游戏。
之后创建工程,在创建工程时,注意UE4的版本需要与我们编译的UE4版本相一致,否则代码估计会编译不过。
创建之后,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编辑器。
在编辑器中设置编译配置信息,依次选择 文件->打包项目->Build Configuration->发布客户端。
之后开始编译生成游戏客户端,依次选择文件->打包项目->窗口->Windows(64),之后会弹出一个框,用于指定编译后的客户端生成路径,选择之后编辑器将开始编译游戏客户端,这里依旧需要花费一段时间,具体取决于你电脑的配置了。
一般来说,只要游戏版本与UE4引擎的版本相一致,那么就不会出现编译上的错误。当编译完成之后,游戏将会生成在指定路径下的WindowsClient
的文件夹中,这个文件夹是可以随意拷贝的,游戏依赖的所有文件都已经在这个文件夹下了,直接执行文件夹下的ShooterGame.exe
便能够启动游戏。
三、实现
前面的游戏编译只是准备工作,接下来开始进入正题。
我们的目标是实现ShooterGame的自瞄和透视外挂,在实现之前我们需要先明白自瞄和透视外挂的基本原理。
所谓自瞄外挂,就是可以帮助我们自动瞄准敌人的程序,既然要自动瞄准,按照常理来想,我们肯定要知道敌人的位置,然后也知道自己的朝向位置,之后把自己的朝向设置到敌人的位置上。
而透视,则是可以显示那些我们本看不到的敌人的位置,因此透视外挂需要获取到敌人的位置信息,然后再将其在画面中渲染出来。
在上一篇文章中,我们提到,外挂的实现一般都按照输入、数据处理、输出这3个步骤实现,这个外挂也不例外。
对于这个外挂来说,输入便是敌人的坐标以及自己的朝向信息。数据处理则是一些坐标换算,比如如何将敌人的位置信息换算成敌人的桌面坐标信息等。而输出则是设置朝向以及渲染敌人的位置。
在获取游戏的输入数据之前,我们先了解一下ShooterGame游戏中位置信息和朝向的基本知识。
3.1 基本知识
ShooterGame是一个三维的游戏,因此要在这个三维世界中表示玩家的位置就需要XYZ三个坐标轴表示,ShooterGame的坐标轴如下。XY坐标轴所在的平面即游戏中的地面,而玩家的高度则使用Z轴表示。
一般在FPS游戏中,朝向使用欧拉角,即pitch、yaw以及roll这三个变量表示,其中,pitch表示上下的摆动的角度,yaw表示左右旋转的角度,而roll则表示左右摇晃的角度。在ShooterGame这个游戏中,roll变量并未用到,一直是0。
接下来,我们一步步分析,首先先看输入部分,如何获取敌人的位置和自己的朝向信息。
3.2 获取输入
3.2.1 游戏结构
一般来说,所有人物的位置信息以及玩家的朝向信息在游戏内存中是存储着的。为了能够拿到这些信息,外挂开发者会借助上一篇文章中提到的CheatEngine
以及反汇编逆向分析等手段分析游戏的代码逻辑,从而拿到这些信息。
而ShooterGame的代码是开源的,UE4的代码也是开源的,因此我们只要了解了UE4的一些基本概念之后,便可以很轻松的拿到这些信息。
通过虚幻引擎4术语文档以及《Inside UE》系列文章,我们可以基本了解UE4的大体框架结构如下。
其中,
UGameInstance
类,对应UE中最顶层的Game概念,在游戏创建时产生,直到游戏实例关闭时才被销毁,它通过WorldContext(图中未表现)管理着游戏中的多个世界(World)。一般来说,全局唯一。UWorld
类,对应引擎中的世界(World)概念,它包含了游戏中的所有关卡(Level)。它可以处理关卡流送,还能生成(创建)动态Actor。ULevel
类,对应引擎中的**关卡(Level)**概念,它是用户定义的游戏区域。关卡包含了玩家能看到的所有内容,例如几何体、Pawn和Actor。AActor
类,对应Actor概念,所有可以放入关卡的对象都是 Actor,无论是实体还是非实体,比如摄像机、静态网格体。USceneComponent
类,AActor
本身不包含位置信息,UE将其封装到了UScaneComponent
中,作为AActor
的RootComponent
成员。APawn
类,对应Pawn概念,它是Actor的子类,可以与玩家发生交互的称之为Pawn,它可以由玩家操控,也可以由游戏AI控制并以非玩家角色(NPC)的形式存在于游戏中。AShooterCharacter
类,该类是ShooterGame游戏中的类,非UE引擎中定义的类。该类对应**角色(Character)**概念,它是ACharacter
的子类(图中未展示),而ACharacter
又是APawn
的子类。ACharacter
旨在表示所有的人形的带骨骼的Pawn。APlayerController
类,对应**玩家控制器(Player Controller)**概念,它会获取游戏中玩家的输入信息,然后转换为交互效果,每个游戏中至少有一个玩家控制器。玩家控制器通常会控制一个Pawn或Character,将其作为玩家在游戏中的化身。APlayerState
类,存储Player的一些状态信息。
前面提到,我们需要获取的有:
- 自己的坐标信息。
- 自己的朝向信息。
- 敌人的坐标信息。
基于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解析到的基址和偏移信息通过宏进行表示,下面是一个示例。注意这只是我编译出的游戏的偏移,在你那里是不适用的。
|
|
获取当前玩家控制的pawn对象。
|
|
获取关卡中所有的actor,并过滤出其他pawn的代码。
|
|
获取pawn的位置信息。
|
|
获取当前玩家的朝向信息。
|
|
3.3 数据处理
获取到基本信息,之后就需要对数据进行处理了。
3.3.1 透视
这里先考虑实现透视功能,透视功能主要是把不可见的敌人位置在游戏画面中展示出来。敌人的位置信息我们已经拿到了,那接下来问题就是如何将敌人的位置信息转换成屏幕的坐标信息?
下图中,虚线所在的方框可以理解为我们的屏幕,实线的红色小人是敌人,虚线的红色小人是敌人在屏幕上的投影,而蓝色小人则是我们自己。这里我们建立一个视图坐标系,以我们自己所在地方为原点,我们所看向的中心点为Z轴的正方向,右方为X轴正方向,上方为Y轴的正方向。
假如我们知道敌人在这个坐标系中的坐标(X,Y,Z)、屏幕的宽和高(width, height)以及我们的视野夹角(FOV),那么我们通过等比计算就能很容易的得到敌人在屏幕上的位置。
这里推导了物体在屏幕上的X’坐标,Y坐标同理。
|
|
注意,这里得到的X’的值是在我们视图坐标系中屏幕上的坐标值,但实际的屏幕坐标表示并不以视图坐标系表示,而是以屏幕的左上角为原点,以原点向右为X正方向,以原点向下为Y轴正方向。所以,为了得到最终的屏幕坐标,还需要稍微调整下。
相应的代码如下。
|
|
以上的计算结果的前提都是基于视图坐标系的,但实际上,我们自己以及敌人都是在世界坐标系中的,因此,为了方便计算,需要将我们和敌人的世界坐标都转为视图坐标。
下图中(画的不是很好),红色的XYZ坐标系是世界坐标系,而蓝色的CameraXYZ则是视图坐标系,其中CameraZ与我们的朝向一致。由于在游戏中并没有使用Roll角,因此CameraX始终在XY平面上,其z变量恒为0。而CameraY则与CameraX、CameraZ互相垂直。
现在我们需要先在世界坐标系中表示出我们的视图坐标系,之后任意一个世界坐标与视图坐标系相乘后,将会得到世界坐标在视图坐标中的映射。
我们先来看下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在世界坐标中的表示如下。
|
|
继续来看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在世界坐标中的表示如下。
|
|
再来看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在世界坐标中的表示是。
|
|
现在假设有一点,在世界坐标系中的坐标是(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]
$$
拿到了该点在视图坐标中的坐标后,基于前面的步骤便可以得出其在屏幕上的坐标值了。
相应的代码实现如下。
|
|
计算屏幕坐标的逻辑比较复杂,自己也是研究了好长时间才明白的(主要是矩阵、三角函数之类的知识长期不用都忘了),其实对于我们写外挂来说,需要的主要就是世界坐标转换为屏幕坐标的功能,除了上面我们实现的world_to_screen
方法外,UE4已经给我们提供了类似的函数,即ProjectWorldToScreen
函数,其定义如下。
|
|
其中,PlayerController
以及WorldPosition
参数我们都已经知道了,那么直接调用这个函数就可以得到敌人在屏幕上的坐标信息了。相较于上面长篇大论的理论来说,要简单的多😁。
拿到屏幕坐标信息后,直接在屏幕上将敌人绘制出来即可,这个在后面的输出部分说明。
3.3.2 自瞄
自瞄功能的数据主要分为2步。
- 选出合适的敌人作为自动瞄准的对象。
- 基于自己和敌人的位置信息计算出朝向值。
所有敌人的位置信息已经有了,那么哪个敌人应该作为自动瞄准的对象呢?一般来说,比较符合直觉的习惯是选择离我们准星最近的敌人作为瞄准的对象,所以,第一步就需要我们先找到这个离我们准星最近的敌人。
我们的准星其实就是游戏画面的中心,获取所有敌人的屏幕坐标位置,然后计算敌人屏幕位置与屏幕中心位置的距离即可。对于那些在我们背后,不在我们画面中的敌人来说,直接忽略就行了。
|
|
得到离我们准星最近的敌人后,接下来就是计算瞄准该敌人时需要的角度信息。
基于基础知识,我们知道朝向由pitch和yaw这两个角度表示,其中pitch的范围是180°,在不同游戏中的表现方法不同,有的游戏使用-90° ~ +90°的方式表示,有的游戏使用0° ~ 180°的方式表示,而在ShooterGame中,则是使用了270° ~ 90°的方式表示。而yaw的范围则一般是使用0° ~ 360°的方式表示,ShooterGame中也是如此。
在上图中,蓝色小人我们自己,而红色小人则是敌人。我们自己当前的朝向是红线指向的地方,而蓝线则是朝向对准敌人的地方,如果要实现自瞄,那么就需要将我们的朝向设置成蓝线的状态。
这里我们首先计算蓝线的pitch值,pitch值简单来说就是我们目标朝向相对于X ~ Y平面的角度。在下图中,蓝线是我们对敌人朝向的在X ~ Y平面上的映射,而红线则是对敌人的朝向。注意,这里为了统一,两条直线的起点都为蓝色小人的脚底。
我们要计算的pitch值是朝向相对于XY平面的角度,那么在图中便是a角。假设敌人与我们在坐标X轴上的差值是dx,在坐标Y轴上的差值是dy,在坐标Z轴上的差值是dz。那么a角角度的计算方式如下。
|
|
接下来我们计算yaw值,yaw值简单来说就是以X轴为起点的绕Z轴的角度。在下图中,蓝线是对敌人朝向的在X ~ Y平面上的映射,红线是指向X轴正方向的一条线,而我们要求的yaw值便是图中的b角。
我们依旧假设敌人与我们在X轴上的差值是dx,在Y轴上的差值是dy,那么计算yaw值的代码如下。
|
|
到了这里,我们便拿到了朝向敌人的朝向值。
3.4 实现输出
经过上一步的数据处理,现在我们已经拿到了我们想要得到的数据了,接下来就是将这些数据展示出来。
对于透视来说,我们需要通过画框等手段将敌人的位置显示出来,而对于自瞄来说,我们需要设置我们的朝向为离我们准星最近的敌人。
3.4.1 透视
这里依旧先说透视,常见的透视外挂都是方框透视,即把敌人的位置信息通过方框的形式显示出来,这里我们也采用相同的方式。画框的方式有多种,比如GDI画框,或是DirectX画框,而UE4中也提供了绘制相关的函数,因此这里我们直接使用UE4的函数。
这里的方框实际上是个矩形,UE4提供了绘制矩形的函数AHUD::DrawRect
,但这个矩形是实心的,我们想要的是空心的矩形,因此这里我们通过画4条直线的方式模拟一个矩形,绘制直线的方法定义如下。
|
|
函数的地址依旧通过PDB解析得到,之后直接调用这个函数就行了。
这里的一个重点是,为了有三维的效果,方框需要遵循近大远小的原则,那么该如何基于敌人的距离计算方框的宽度和高度呢?我们继续看透视部分的这张屏幕投影图。
在这张图里,我们通过等比变换得到了点A在屏幕上的坐标,假设敌人在A点的宽度是W,同样基于等比变换,我们就可以得到其在屏幕上的W',高度同理。
|
|
其中z值和depth值我们在实现world_to_screen
函数时已经拿到了,这里只差W和H值了。这两个值我是结合游戏中人物的移动坐标的变化进行估计的,其中W是100,H是300,在最终实现的效果来看,结果还可以。
得到了W’和H',再结合上面的画框函数,我们就能够完美的画出人物方框了。
3.4.2 自瞄
相较于透视,自瞄的输出逻辑更加简单直接一些,我们直接将计算出的朝向设置成我们当前的朝向即可。计算出的朝向以及要设置的朝向偏移我们是都已经知道了的。
不过这里也有一个问题,何时触发自瞄这个操作?常见的外挂一般在点击鼠标右键时开启自瞄功能,这里我们也采用这种形式。相应的代码实现如下。
|
|
3.5 执行时机
自瞄透视外挂通过读取游戏中玩家与敌人的坐标数据即可实现,为了尽可能的避免对游戏进程产生影响,一般通过进程外的方式实现,但我们这个外挂中,调用了一些游戏自己的函数,因此只能实现在游戏进程内,其中绘制相关的函数还需要游戏中的hud变量,因此我们这个外挂需要通过hook的方式得到相应的指针变量,这个hook的时机便是游戏绘制HUD对象的时机。
这里我们使用detours hook库,hook点是DrawHUD
方法。简单代码实现如下。
|
|
3.6 效果
有了执行时机,自瞄和透视的输入、处理、输出步骤也都实现了,基本上一个外挂也就实现了,下面是外挂的效果。
完整的代码参见GitHub链接。
四、小结
在这一部分,我们基于UE4的ShooterGame实现了一个自瞄透视外挂,相较于上一篇文章中介绍的连连看外挂,这个自瞄透视外挂要复杂的多(主要是需要的数学知识更多了),但也要有意思的多。其实这个外挂还可以继续完善,还可以实现其他更加强大的功能(比如子弹跟踪功能、显示玩家血量名称等),反正游戏的代码都在你的手里,想怎么折腾就怎么折腾。
通过这两篇文章,可以发现其实一个外挂的实现还是比较简单的,只要你能够拿到游戏中想要的数据,然后做一些的数据处理和输出就可以了。对于游戏开发人员来说,简直不要太简单。但难点也在于此,如何才能拿到我们想要的数据?这个就需要通过逆向分析游戏的代码了,有兴趣的可以去了解下。我对这些逆向分析不是很熟,这里也就不做过多说明了。
在下一篇文章中,我们基于这两个外挂实现的过程,尝试站在游戏开发者的角度,思考如何对抗外挂。