Unreal Engine 游戏架构及测试方案介绍
1、前言
目前针对采用UnReal引擎的游戏,尤其是完全采用UnReal提供的网络实现的游戏,常规的协议测试工具和方法比较难于提取有效的信息。而针对内存进行修改如果不了解UnReal的架构实现也存在很大的盲目性。本文讲述了采用UnReal引擎的游戏的框架与运作原理,并提出了一种针对该类型的游戏测试方案。文章末尾还介绍了一款针对UnReal游戏开发的测试工具。(由于UnReal引擎非常庞大,很难做到掌握所有细节,本文的视角会偏向测试中所需要的一些知识点进行介绍,如有疏漏或错误的地方,欢迎指正!)
2、UnReal游戏框架
基于UnReal的游戏由以下几部分组成: 服务器、客户端、渲染引擎及引擎支持代码。
Unreal控制着所有的玩家和物体间的游戏性和交互。在单玩家游戏中,Unreal客户端和Unreal服务器在同一台机器上运行;在网络游戏中,有一个机器用于专用服务器;所有连接到这个机器上的玩家是客户端。
Unreal游戏与传统的游戏框架有显著差异,在UnReal框架下“服务器”与“客户端”的概念仅仅体现在一个名为“NetMode”的脚本枚举量上。而就代码而言,两者内容是完全一致的(独立于UnReal框架的模块除外)。 在UnReal的客户端/服务器模型中,服务器与客户端同时执行同一套脚本逻辑。服务器拥有完全权威,而客户端则作为聋哑终端的形式实现。客户端只是可以显示服务器下发的数据并将本机操作所对应的函数告知服务器。客户端无法欺骗/或者破坏其他客户端的游戏状态(攻击者只能利用病态的服务器执行的函数来使服务器更改自己或者其他客户端的状态)。
所有的游戏播放都发生在一个“关卡”中,它是一个包含着几何体和Actor的独立环境。尽管UnrealServer可以同时运行多个“关卡”,但每个关卡独立运作并且彼此屏蔽(客户端不会执行到“关卡”调度相关的脚本): 物体(Actor)不能在不同“关卡”间穿行,而且一个“关卡”中的物体不能和另一个“关卡”中的物体进行通信。
地图中的每个Actors可以由玩家控制(在网络游戏中可以有很多玩家)或者由脚本控制。当Actors在脚本的控制下时,那么该脚本完全地定义了该Actor如何移动及如何与其它Actor进行交互。
对于世界中所有这些到处跑动的Actor、执行的脚本及发生的事件,他们是如何运转的呢?
为了管理时间,Unreal将游戏运行的每秒钟分隔为"Ticks"。一个Tick是关卡中所有Actors更新所使用的最小时间单位。一个Tick一般是一秒钟的1/100到 1/10。tick时间仅受到CPU功率的限制,机器速度越快,Tick持续时间越短。
UnrealScript中的某些命令的执行只需要使用零Tick的时间(也就是:它们的执行没有占有任何游戏时间),也有些命令需要占用很多Ticks。需要占用游戏时间的函数称为"Latent functions(潜伏的函数)"。一些Latent functions函数的例子包括 Sleep , FinishAnim 及 MoveTo 。UnrealScript中的Latent functions仅可以从在一个状态的代码中进行调用(所以也称作"state code(状态代码)"),而不能从一个函数的代码中(包括在一个状态中定义的函数)进行调用。
当一个Actor在执行一个Latent函数,那个Actor的状态执行不会继续直到Latent函数执行完毕。然而,其它的Actor或者VM可能会调用该Actor内部的函数。最终的结果是所有的UnrealScript的函数可以在任何时间被调用,甚至在Latent函数没有执行完毕的情况下。
按照传统的编程术语来说,UnrealScript就像在关卡中的每个Actor有它们自己的执行“线程”一样工作。在内部,Unreal不使用Windows线程,因为那将是非常低效的(Windows不能高效地处理同时发生的成千上万的线程)。UnrealScript 模拟线程实现(对于脚本代码是透明的),这样就省去了线程调度的开销,从一部分程度上抵消了VM执行脚本所花费的额外时间。
所有的UnrealScripts将彼此独立地执行。如果有100个怪物正在关卡中走动,那么所有的这100个怪物的脚本在每个"Tick"中都正在同时地且独立地执行着。
2、引擎实现及子系统概述
Core:
Core是引擎的底层实现,由纯原生代码构成。它封装了大量的类来实现跨平台的基本功能抽象,如内存分配/释放、输入/输出、数据结构底层实现等。同时Core还负责加载并解析UnReal脚本,将其中所有定义的类与函数生成为实际的UObject内存对象(函数也作为对象存储,与STL的函数对象类似。可以看作“实现某种操作的类”),并对编译后的脚本字节码进行解释和进一步的操作(VM)。
UnReal中的大部分高层次交互都是发生在UObject之间,游戏开发者在基于UnReal引擎之上二次开发时,最终操作的数据仍然是从UObject派生的各种对象(除非操作的一些完全独立与UnReal引擎的独立开发子系统),而构造和操作UObject最简单的方式就是使用UnRealScript脚本。并且UObject类实现了一套完善的反应以及检查系统,相比之下如果游戏开发者自己去实现类似功能细节则会带来很多不必要的开销,并且代码在经过大规模测验之前可能包含潜在的安全隐患。
Engine:
Engine模块是虚幻引擎的大部分实际工作发生的地方; 它是各种游戏相关的功能的集合,这些功能比Core“使用原生代码更合适”的代码更加具有专用性,但是又比其它地方的更加具有专用性的子系统具有更好的通用性(平台独立及游戏独立)。所以在这两个模糊地界线之间有大量的混合代码(原生+脚本)。 它也提供了一套脚本框架,让游戏开发者能够很快的基于这套框架的基础类型之上派生出贴合游戏设计的子类。
UnRealScript脚本:
UnrealScript原始脚本代码(.uc文件)在编译之后成为一系列类似于p-code(移植码)或Java字节代码的字节码(.u文件)。这使UnrealScript具有平台独立性,这可以使Unreal的客户端和服务器端组件直接地移植到其它的平台上,包括Mac、Unix、PS、XBox、Wii甚至是手机平台。并且所有的版本通过执行相同的脚本都可以很容易地进行交互操作。
UnRealScript特点:
没有指针并自动进行垃圾回收的环境;
一个简单的单继承类图;
编译时进行强类型检查;
安全的客户端执行的"sandbox(沙箱限制)";
像C/C++/Java代码一样熟悉的外观和感觉。
3、UnRealScript语法
1、类
在UnRealScript里,每个脚本对应的是具体的一个类。在脚本文件开始部分声明了类、类名、父类以及与这个类相关的其他信息。
例如:
class MyClass extends ParentClass
[Specifier
Specifier
Specifier];
在这里声明了一个名为 MyClass 的新类,它继承了 ParentClass 的功能。这意味着每个类中都包含其父代类中的所有变量、函数和声明。此外,它接下来可以添加新的变量声明,添加新函数(或覆盖现有函数),以及添加新的声明(或向现有声明中添加功能)。之后可以声明几个能影响这个类的可选修饰符。其中有一些是我们比较感兴趣的。
部分类的修饰符:
Native(PackageName) 该类由原生代码支持(实现细节不出现在脚本中)
NativeReplication 该类的变量复制函数在原生代码中处理
DependsOn(ClassName[,ClassName,...])该类依赖于其他类
Transient 该类的对象永远不应该保存在磁盘上
Config(IniName)该类可以在配置文件(.ini)中存储和使用数据
NoExport 该类未导出(PDB内没有结构信息)
2、变量
UnRealScript的变量分为两种类型:实例变量(Var)和局部变量(Local)
实例变量作用于整个对象,而局部变量只属于某个函数。实例变量的数值是游戏性的最终体现。(UnRealScript没有全局变量概念,所有变量都是从属于某个类的属性)
3、状态
4、函数
函数定义以 function 关键字开始(特殊的事件函数以event 关键字开始)。后面可以跟随任意的函数返回类型,然后是函数名及在括号里所包含的函数参数。
function Server float Sim_GetTime(wState ws)
{
return 3.00;
}
UnReal引擎在Core中实现了一套和Windows事件机制类似的系统,当针对某个对象的某件特定的事件发生之后,引擎会调用该对象对应的事件函数进行响应。
函数修饰符:
Static :静态函数,可以在没有对象实例的情况下使用
Native :该函数在脚本中调用,但是实现在原生代码中(扩展功能经常用到,而扩展功能又经常出问题,因为它们可能没有经过长时间的大规模安全测试)
Exec :该函数可以通过控制台执行
Latent :该函数仅能在“状态”代码中调用,并且在游戏过去一段时间之后返回。
Simulated :仿真函数(播放动画、声音、模拟动作等不修改对象任何状态的行为)
Server :该函数必须发往服务器执行
Client :该函数必须发往客户端执行
Reliable/UnReliable :说明一个复制函数是可靠/不可靠的同步。
函数定义语句括号内就是函数的参数。参数可以是任何UnRealScript类型(具体内存结构则依据不同类型而不同)。
函数参数修饰符:
Out:类似于C++中的引用传递。表明函数可能修改这个参数值
Optional :表明该参数为可选
Coerce :函数将对该参数进行强制类型转换(尤其是在字符串处理时)
在某些情况下,开发者需要调用一个函数的特定版本,而不是当前范围内的版本,这时会用到一些函数调用修饰符。
函数调用修饰符:
Global:调用最子类的全局(非状态) 函数版本
Super:调用函数在父类中的相应的版本, 根据特定的情境被调用的函数可以是一个状态或者非状态函数。
Super(classname):调用指定类中的相应的函数, 根据特定的情境被调用的函数可以是一个状态或者非状态函数。
结合使用多个调用修饰符(也就是 Super(Actor).Global.Touch )是无效的。
4、UnRealScript执行
UnReal采用事件驱动模型,与Winodwos类似,引擎在一个消息循环中将消息分发到每个对象,开发者通过编写或者重载事件函数来实现对消息的响应。
在初始化时,引擎会将所有的脚本文件解析成原生对象并加载到内存中,其中脚本函数被解析成UFunction对象。在有事件发生并寻找到对应的消息处理函数时,引擎会找出该脚本函数所对应的UFunction对象,然后调用ProcessEvent函数进行处理。非事件函数、原生函数则由CallFunction函数进行处理。在ProcessEvent与CallFunction下层还有一些分支,例如ProcessRemoteFunction、ProcessInternal等,这些函数会根据脚本函数的标志位来确定应该由谁负责处理。
5、UnReal客户端/服务器交互
1、概述
虚幻向多玩家游戏中引入了一个新的方法,术语名称是 generalized client-server model(广义的客户端-服务器模型) 。 在这个模型中,服务器仍然控制着游戏状态的变化。 然而,实际上是客户端在本地维护游戏状态的精确子集,并且在大致同样的数据上,客户端可以通过执行和服务器一样的游戏代码来预测游戏的流程,从而最小化了在两个机器间交互的数据的数量。 服务器通过复制相关actors及它们的属性把关于世界的信息发送到客户端。 客户端和服务器也可以通过复制的函数来进行通信,仅在拥有调用函数的Actors的服务器和客户端之间复制函数。
如果是 server(服务器) ,则可以把当前游戏状态传达给我的所有客户端。(客户端不能复制状态到服务器,杜绝了传统游戏中由于协议设计失误导致的客户端直接修改数据欺骗服务器的现象)
如果是 client(客户端) ,则可以把我请求的运动(需要执行的脚本函数)发送到服务器,并且可以从服务器接收新的游戏状态信息,把我的当前世界的近似视图描画到屏幕上。
2、连接
UnReal提供两种类型的网络连接: 自己动手和通过虚幻进行连接(UDP)的方法。某些开发商会在独立子系统(例如商城或其他增值系统)中使用自己的连接方式并通过脚本接口与游戏进行数据交换。
在采用UnReal自身提供的连接方式里,引擎采用 UChannels 的概念将不同的数据类型交换使用在同一个 UNetConnection 上。通道分为三种类型,使用 UControlChannel 可以在客户端和服务器之间达成共有通讯协议, UActorChannel 向客户端来回发送游戏状态相关信息,而在游戏中使用UFileChannel 可以将虚幻包从虚幻服务器发送到客户端。
3、数据组织
UnReal的数据包组织过程比较复杂,这也导致常规协议测试方法很难有效的从协议包中提取出有用的信息。具体的数据封装结构超出了本篇文章讨论的范围。简单来说,网络驱动程序 (UNetDriver ) 主要负责管理与其他引擎实例的连接 ( UNetConnection )。这个链接会处理 actors将要在其上进行通信的各种通道。UNetConnection 将不同的UChannels 组织到一起进行传输。UChannels 有选择性地合并某些数据串然后加上合并结构信息(由SendBunch完成)后交给UNetConnection 处理, UNetConnection 对数据进行序列化(压缩、加密)后加上ACK/NAK信息后传递给传输层。传输层再进行最后的封装后进行发送,并处理异常情况:故障包、滞后包(随附Tick(更新)以及包损失和重复包。
在接收方面,引擎先通过类似的流程将数据进行处理后分发给对应的UChannels 。UChannels 在收到足够的信息后还原为完整的数据串并经行处理。如果接收者角色是服务器,将抛弃数据串中与对象状态相关的一切信息,而客户端则会根据服务器下发的信息创建或者刷新已有对象的属性。
如果接受到的数据串内包含的是函数执行申请(数据串内此时存储的是需要调用函数的对象信息摘要和函数序号),接收者将从自身的UObjects对象表中找到对应的函数并进行调用,如果对象信息不正确或者找不到对应的函数,该申请将被忽略(如果客户端/服务器脚本不一致就可能出现类似情况)。
6、测试方法思考及测试工具的运用
针对采用UnReal引擎的游戏,由于引擎自身实现了大部分的容错和异常处理,加上二次开发者大部分代码都采用脚本形式,而脚本实际以沙箱模式运行与虚拟机中,所以其自身的数据安全性比较高。纵观各类采用UnReal引擎的游戏,其漏洞大都来自于二次开发者脚本代码中的逻辑错误,而UnReal引擎服务器/客户端用同一套脚本的架构让测试人员可以很容易的从客户端脚本中分析出服务器的逻辑。
测试人员在熟悉游戏的逻辑之后,设计出测试用例,并将它们整理为与具体函数相对应的执行流程。例如,需要测试建立房间的各种特性,我们可以先利用本文作者提供的UeTraceView工具记录下创建房间所执行的所有脚本函数,然后在UeTraceView配套DLL(每个游戏会对应一个特定的DLL)Console窗口里用ts命令测试该脚本函数是否命中,之后选出起作用的脚本函数,查询脚本文件中该函数的参数类型,最后构造参数进行调用,完成用例执行。
示例:
1、执行游戏内操作,得到执行函数序列
2、使用ts命令,并重复执行该动作。从执行序列中确定作用函数
3、查看对应脚本文件,找到参数定义后构造参数,使用d命令测试调用结果