雷神之锤3 网络同步框架
链接:https://www.jfedor.org/quake3/?utm_source=chatgpt.com
Quake III Arena 是一款联网多人第一人称射击游戏,由 id Software 开发,于 1999 年 12 月发布。该游戏的源代码于 2005 年 8 月根据 GNU 通用公共许可证的条款发布。
我们将全面讲解游戏引擎的网络组件,从一般的运行原理到实际的网络协议细节。此外,我们还将介绍该引擎的一些精妙特性,例如事件系统以及使用虚拟机将引擎代码与游戏逻辑分离的做法。
配套软件
尽管我们尽力提供足够的信息来实现网络协议,但有时代码比自然语言描述更容易理解其功能。本文附带发布了该协议的一个全新实现:一个代理,用于解析服务器和客户端之间交换的所有消息,并可选择重写部分数据包以提供概念验证的瞄准机器人功能。除了帮助理解协议之外,该代理还可用于实验,因为它能够模拟网络延迟和数据包丢失。其源代码可在 获取https://github.com/jfedor2/quake3-proxy-aimbot。
概述
Quake 3 采用客户端-服务器模型:所有参与比赛的玩家都连接到同一服务器。客户端运行在玩家的设备上,负责采样玩家的输入(键盘、鼠标)并将其发送到服务器。服务器位于本地网络或互联网的某个位置,运行模拟并将世界状态发送到客户端,客户端会将其渲染呈现给玩家。尽管服务器是游戏运行的权威,但客户端并非“愚蠢”的。为了减轻网络延迟的影响,与本地玩家移动相关的部分模拟也在客户端执行。这种被称为客户端预测的机制于 1996 年首次在 QuakeWorld 中引入,也是像 Quake III Arena 这样的快节奏多人游戏能够在游戏发布时流行的高延迟拨号调制解调器连接下流畅运行的原因之一。
作为一种特殊情况,当游戏以单人模式(对抗机器人)进行时,客户端和服务器在同一台机器上、在同一个进程中运行,但操作原理保持不变。
引擎与游戏代码
Quake 3 的代码主要分为两部分:引擎部分和游戏部分。游戏部分包含游戏规则,定义了游戏中不同对象之间的交互方式:例如,当火箭击中玩家时会发生什么,或者当玩家的生命值降到零以下时会发生什么。引擎部分包含操作系统相关的代码(图形、声音、输入、网络)、渲染器、机器人库,以及其他一些内容,例如在线网络协议的具体细节。引擎部分和代码部分都在服务器和客户端上运行。
这种划分非常有趣,原因不止一个。《Quake III Arena》的完整源代码于2005年根据GNU GPL协议发布。在此之前,只有获得id Software引擎授权的公司才能同时获得引擎和游戏部分。像《American McGee's Alice》和《重返德军总部》这样的游戏都是在这样的协议下开发的。但在完整源代码发布很久之前,几乎在游戏发布后立即,只有游戏部分的源代码就公开发布,无需付费许可。这使得任何人都可以创建和分发所谓的“模组”(mod),即对游戏规则的修改。再加上可以创建自己的游戏资源(地图、模型、纹理),这使得制作几乎全新的、具有独特外观和玩法的游戏成为可能。这种对模组和完全转换开发者的友好态度在《Doom》和《Quake》系列游戏中很常见。这并没有给id Software带来经济损失,因为运行模组的人仍然必须拥有原版游戏。
并非所有修改都必须是全新的游戏。有些修改仅仅引入了新的计分规则或修改了游戏的物理机制。从本文的角度来看,最有趣的是,负责核心游戏行为的大部分网络代码都位于游戏端。因此,游戏爱好者可以制作模组来改变网络延迟对玩家的影响。一个名为“Unlagged ”的模组旨在通过计算玩家在尝试射击时看到的其他玩家的位置,而不是玩家当时在服务器权威视角下的实际位置来补偿网络延迟。
虚拟机
引擎与游戏代码的分离引发了一些有趣的技术问题。由于模组的存在,游戏代码应该被视为不可信代码。《雷神之锤 3》包含一项功能,当客户端加入运行着之前未曾见过的模组的服务器时,会自动将游戏代码下载到客户端。从安全角度来看,允许服务器管理员让客户端运行任意原生代码会立即引发危险信号。
为了解决这个问题,Quake 3 中的游戏代码部分被编译为字节码,以供为此目的创建的虚拟机使用。这限制了游戏代码的功能,并阻止其访问用户系统的其余部分。但从性能角度来看,在游戏过程中解释字节码并非最佳选择。为了获得虚拟机的沙盒优势和本机代码的性能,Quake 3 在加载游戏时将字节码编译为本机代码。或者至少在支持的架构之一(x86、PowerPC)上运行时,它默认会这样做。它仍然保留了以解释模式运行字节码的能力。即使性能不再是问题,这种虚拟机设置也有一个很大的缺点:它使在开发过程中调试游戏代码变得更加困难。因此,Quake 3 游戏代码也可以与引擎的其余部分一起直接编译为本机代码,并作为动态库加载,从而使其对调试器更加友好。
Quake 3 中有三个虚拟机,一个名为game,它是游戏逻辑的服务器端,一个名为cgame ,是client game的缩写,它是游戏逻辑的客户端,还有一个名为ui,负责客户端用户界面,这意味着游戏菜单也可以在 mods 中自定义。
为了在引擎代码和游戏代码之间进行通信,Quake 3 提供了虚拟机调用和系统调用。例如,当引擎想要让游戏为单个服务器帧运行计算时,它会进行GAME_RUN_FRAME虚拟机调用,从而G_RunFrame()在虚拟机内部运行相应的函数。而当客户端游戏代码想要让引擎渲染单个帧时,它会进行CG_R_RENDERSCENE系统调用,最终调用RE_RenderScene()渲染器库中的函数。所有这些都发生在同一个线程上,Quake 3 中没有多线程,除非渲染器中存在可选的线程。
事件系统
Quake 3 中的所有操作,无论是客户端还是服务器,都是对事件的响应。玩家的输入,例如鼠标移动和键盘按下,以及从网络接收的数据包,都经过统一的事件系统。甚至时间的流逝也通过单独的事件类型传递给引擎。除了将引擎与操作系统特定的代码解耦之外,这还带来了一种有趣的可能性。在正常游戏过程中,可以将所有经过队列的事件记录到一个日志文件中。然后,就可以以一种特殊模式启动游戏,该模式仅处理先前保存的日志文件中的事件。在此模式下,引擎会忽略所有正常输入,例如鼠标、键盘、网络甚至时间。即使某些外部条件发生变化,重放此类日志所产生的游戏会话也与原始会话完全相同。由于时间的流逝也会被记录下来,因此游戏将渲染相同的帧,并以相同的(虚拟)每秒帧率运行。这种重放会话的能力在调试难以重现的错误时非常有用。
演示
虽然事件日志是一项旨在帮助开发人员调试代码的功能,但还有另一种机制,这次是为玩家创建的,它也可以录制并重放游戏过程。在屏幕录制和互联网直播实用化之前,玩家会分享演示文件来向他人展示他们的成就。演示文件与事件日志类似,因为它们将游戏进度存储在一个文件中,但它们的不同之处在于,它们不是存储所有事件,而是存储客户端在录制会话期间从服务器接收的网络数据包。这些数据包包含足够的信息来重现会话。
播放演示与播放日志文件不同。虽然游戏玩法看起来与原始会话期间相同,但这次的目标并非追溯录制时执行的确切代码路径。由于播放演示时时间会自然流逝(而不是从日志中读取),因此如果播放的计算机比原始计算机更快或更慢,渲染的帧数可能会有所不同。即使在同一台计算机上,虽然渲染的帧数相似,但它们很可能不会出现在与录制演示时完全相同的时间戳上。
有一种名为“timedemo”的特殊演示播放模式,用于测量特定机器运行游戏的速度(基准测试)。在此模式下,帧的处理速度尽可能快,但虚拟的游戏时间每帧固定递增 50 毫秒。因此,游戏以每秒 20 帧的虚拟速率运行,实际帧速率则使用真实时钟进行测量。演示播放结束时,会打印出该帧速率的平均值,用户可以将其与在另一台机器上以“timedemo”模式重放同一演示的结果进行比较。
工作原理
让我们仔细看看客户端和服务器上发生的事情以及它们之间的信息流。
服务器以每秒 20 帧的固定速率运行其帧。在每一帧中,它会遍历所有实体(游戏世界中存在的对象),并让它们“思考”。此时,火箭会根据其速度移动,造成伤害,并运行其他游戏逻辑,包括检查是否达到碎片限制等条件。除了玩家的移动之外,所有操作都发生在服务器帧计算中。在执行游戏逻辑并确定游戏世界的当前状态后,服务器会将该状态以所谓的“快照”的形式传达给所有客户端。
玩家在服务器上何时移动?服务器收到包含用户移动意图信息(称为用户命令)的网络数据包后,会立即在服务器帧之间移动。我们将在讨论客户端发生的情况时再讨论这个问题。
客户端会尽可能快地运行其帧(最高可达 125 Hz,如果启用了垂直同步,则为显示器刷新率)。客户端的帧速率可能会有所不同,但通常会比服务器使用的 20 Hz 快。在客户端上处理的帧数比在服务器上多有什么意义呢?毕竟,如果服务器掌握着世界状态,而客户端每秒仅获取 20 次该状态,那么在获得下一次更新之前,它不是会多次渲染同一幅图像吗?并非如此。为了使视觉效果更流畅,客户端会在服务器提供给它的两个状态之间进行插值。如果它知道时间t和时间t+50 ms时的世界状态,并且需要在这两个时间点之间渲染额外的帧(因为它拥有一块能够以 60 FPS 运行游戏的优秀显卡),它会在已知的两个状态之间插值所有可见对象的位置。这意味着,当客户端在t+16 毫秒时渲染帧时,它已经需要从t+50 毫秒时接收到有关服务器帧的信息!唯一可能的方法是,客户端故意延迟其相对于从服务器接收的内容所看到的世界。这还不包括网络造成的延迟:当客户端从服务器接收网络数据包时,它所包含的信息已经比数据包从服务器传输到客户端所需的时间晚了几毫秒。这个数字在本地网络上几乎为零,但在通过拨号调制解调器访问的 1999 年互联网上绝对不可忽略。
如果包含下一个快照的网络数据包延迟或丢失,并且客户端没有状态可以进行插值,会发生什么情况?这时,客户端会被迫采取通常会尽量避免的做法:如果物体继续以当前方向移动,则推断或猜测它们的位置。对于像火箭这样直线移动或仅受重力影响的物体来说,这可能不是什么大问题。但其他玩家的移动方式难以预测,因此游戏通常会选择插值而不是推断,即使这意味着除了现有的网络延迟之外,还会人为地延迟最多一个服务器帧的所有内容。
以上描述适用于所有游戏对象,除了最重要的一个:玩家。这种情况比较特殊,因为玩家的眼睛是我们观察游戏世界的第一人称视角。任何延迟或流畅度的缺失都会比其他游戏对象更加明显。因此,将用户输入发送到服务器,等待服务器处理并返回当前玩家位置,然后才使用这些延迟信息渲染游戏世界并非明智之举。Quake 3 客户端的工作原理如下:它会在每一帧中采样用户输入(鼠标在哪个方向上移动了多少,按下了哪些键),构建一个用户命令,该命令将用户输入转换为与实际使用的输入方式无关的形式。然后,它会立即将该用户命令发送到服务器,或者将其存储起来,以便与后续帧中的用户命令一起批量发送。服务器将使用这些命令来执行玩家移动并计算权威的世界状态。但客户端不会等待服务器执行这些操作并返回包含该信息的快照,而是会立即在本地执行相同的玩家移动。在这种情况下,无需插值——移动命令会应用到从服务器收到的最新快照上,结果可以立即在屏幕上显示。从某种意义上说,这意味着每个玩家都生活在自己的机器上的未来,而世界其他地方由于网络延迟和插值所需的延迟而落后。在客户端应用用户输入而不等待服务器响应的过程称为预测。
通常情况下,客户端对玩家运动的预测会与服务器的计算完全一致。但也有可能出现结果不一致的情况。这种情况的发生,意味着服务器上发生了一些客户端当时不知道的事情,例如与其他玩家发生碰撞,或者被火箭弹击中后被击退。当这种情况发生时,客户端当然需要修正其对玩家位置和方向的判断,但它会尽量避免完全依赖服务器提供的坐标。如果误差较小,它会在接下来的几帧内平滑地在错误位置和正确位置之间过渡。
网络协议
Quake 3 中的所有网络通信都基于 UDP 协议。UDP 协议是不可靠的,这意味着单个数据报(数据包)可能会乱序到达、重复到达,甚至根本无法到达。操作系统的网络层没有任何确认或重传机制,这些机制由应用程序在必要时自行实现。(与延迟一样,数据包丢失在本地网络上几乎不会造成问题,但在互联网上却屡见不鲜。)
这种不可靠的模型非常适合像《雷神之锤 3》这样的快节奏实时游戏——将确认和重传数据包的任务交给操作系统的网络层几乎没有意义。当操作系统得知数据包在网络上丢失时(因为一段时间内对方没有确认数据包),数据包中包含的信息其实已经过时了。即使某些信息确实需要重传,最好还是在应用程序层面处理,因为应用程序理解消息的格式和含义,可以决定重传某些部分,并用更新的信息替换其他部分。
在《Quake 3》中,服务器向客户端发送消息,客户端向服务器发送消息,不同类型的消息也不同。如果我们只考虑正常游戏过程中的消息交换(忽略初始握手和关卡重启),客户端发送的数据包包含用户命令(玩家的移动)和客户端命令,而服务器发送的数据包包含快照(世界状态)和服务器命令。
客户端命令和服务器命令都是文本命令,用于玩家聊天和发送分数等操作。它们是可靠消息,因此必须确认并在必要时重新传输。每个客户端和服务器命令都有一个序列号,服务器或客户端发送的每个数据包都包含从对方收到的最后一个命令的序列号。客户端和服务器如何决定何时重新传输命令?它们会重新传输所有命令,直到收到确认为止。这意味着,如果网络延迟足够高,即使没有数据包丢失,如果数据包之间的间隔短于数据包到达对方并返回确认所需的时间,命令也会重复执行。
包含玩家移动的用户命令并不可靠,因为它们必须被服务器接收(反正过一段时间就没用了),所以客户端不会无限期地重传它们。但我们仍然希望所有命令都能到达服务器,即使出现数据包丢失。因此,虽然没有直接的确认机制,但默认情况下也会重传用户命令。不过,客户端不会一直重传直到确认,而是会重复发送固定(可配置)的次数。默认情况下,每个用户命令发送两次。这样,即使一个数据包丢失,服务器仍然可以在下一个数据包中获取玩家的移动信息。
用户命令包含时间戳,但该时间戳不会影响服务器的移动时间。移动会在命令收到后立即发生,用户命令的时间戳仅用于与前一个命令进行比较,以确定玩家应移动多远(使用绝对时间,而不仅仅是时间差,因为否则作弊的客户端可能会通过发送更大的时间差来尝试加快移动速度)。
服务器发送给所有客户端的快照包含了在应用了自上一个快照以来发生的所有玩家移动并执行了一帧游戏逻辑之后的世界状态。如果包含一个快照的数据包在网络上丢失,再次发送它实际上毫无意义,服务器只会发送下一个更新的快照,而客户端将不得不通过推断游戏对象的位置(而不是进行插值)来处理信息缺失。这是否意味着客户端不需要告诉服务器它最后收到的是哪个快照,因为服务器根本不关心?相反,由于服务器在发送快照时使用了一种巧妙的增量压缩方案,它实际上非常关心。即使服务器不会重新传输任何快照,它也使用此信息仅发送客户端确认的最后一个快照与当前快照之间的差异(增量)。这样,即使可能出现数据包丢失,每个单独的数据包的大小也会更小,但仍然保证包含足够的信息供客户端重建当前快照。
快照包含接收客户端负责的玩家信息(玩家状态),以及客户端可能需要的所有其他玩家和对象信息(实体)。服务器不一定会发送所有实体,而只会发送玩家当前在地图上的位置可以看到或与之交互的实体。玩家状态和实体状态是结构体,包含一组预定义的字段,这些字段的类型(浮点型或整数型)和位大小以位为单位。
此类数据的增量压缩工作原理如下。当前快照会根据较旧的快照(不一定是前一个快照)进行压缩。如果服务器和客户端之间的网络延迟不可忽略,则通常会使用之前几个数据包的快照,因为服务器确信客户端已收到该快照(因为它已在其中一个数据包中确认了这一点)。这意味着服务器和客户端都需要存储一定数量的旧快照。
服务器会遍历玩家状态中的所有字段,检查哪些字段在旧快照和当前快照之间发生了变化。然后,对于每个未发生变化的字段,服务器只需发送一个比特位,将这一变化告知客户端。实际上,服务器甚至不需要为每个字段都发送一个比特位,它首先发送最后一个发生变化的字段的索引,而对于最后一个发生变化的字段之后所有未发生变化的字段,服务器无需发送任何比特位。
同样,在服务器确定客户端必须了解哪些实体后,它会将此列表与旧快照中的列表进行比较。有些实体将保持不变,有些实体将消失,而有些实体在旧快照中不存在,但新快照中仍会存在。只有当实体存在于新快照中,且与旧快照中的实体不同时,才需要发送实体的实际字段。即使如此,也只会发送已更改的字段,类似于玩家状态的处理方式。
服务器每秒最多发送与其处理的帧数(通常为 20)相同的数据包。客户端可以通过两种方式限制该数量:可以指定每秒希望发送的快照数量,也可以指定每秒希望接收的最大字节数。服务器会跟踪向每个客户端发送的数据量,如果发送快照会导致超出限制,则会跳过该快照。
客户端渲染的每一帧都会发送一个数据包,除非这意味着超出可配置的限制(该限制可设置为每秒 15 到 125 个数据包)。当客户端需要跳过发送某个数据包时,用户输入的命令将包含在下一个发送的数据包中。
在线协议
现在我们知道了客户端与服务器交换的消息类型以及消息包含的信息,让我们看一下通过网络传输的实际位。
在客户端连接到服务器之前,它们会交换无连接或带外数据包。客户端使用这些数据包来查询服务器状态(了解正在玩的地图和游戏类型以及哪些其他玩家已连接),并在玩家决定连接时作为握手的一部分。无连接数据包以四个 0xFF 字节开头。之后是 ASCII 命令。客户端发送诸如getinfo、getstatus或 之类的命令getchallenge,服务器则响应statusResponse、infoResponse、challengeResponse等。其中一个命令比较特殊,因为它后面跟着一个名为userinfoconnect的键值映射,该映射不是以纯 ASCII 格式发送的,而是经过哈夫曼压缩。
客户端连接到服务器后,其余交换的消息将遵循稍微复杂一些的格式。消息的前四个字节是消息序列(按小端序排列)。然后,客户端到服务器的消息还包含一个两字节(同样是小端序)的值,称为qport。服务器使用此值代替 UDP 源端口,以规避某些 NAT 路由器有时会在连接过程中更改端口的行为。此后,数据包中的所有内容都将进行哈夫曼压缩。
此外,为了让作弊者更难作弊,大部分数据包都通过执行异或运算进行混淆。这实际上不能算是加密,因为生成异或密钥的信息是事先以明文形式传输的,但如果不访问引擎源代码,这无疑会使理解协议变得更加困难。对于客户端到服务器的数据包,异或密钥来源于数据包中传输的前三个32位值、初始握手中的质询以及最后确认的服务器命令的文本。对于服务器到客户端的数据包,异或密钥来源于消息序列、相同的质询以及最后确认的客户端命令的文本。由于需要数据包开头发送的信息来解码数据包的其余部分,因此客户端到服务器数据包中的前12个原始字节和服务器到客户端数据包中的前4个原始字节(从霍夫曼压缩区域的开头算起)没有经过异或扰乱。这在一般情况下是不够的,因为12字节长的消息的霍夫曼压缩表示可能超过12字节。因此,派生异或密钥所需的值本身可能经过异或扰乱。但这在实践中不会发生。我们还可以看到,为了能够解码消息,必须从一开始就观察整个连接,包括质询、客户端和服务器命令。
哈夫曼压缩
Quake 3 的网络协议使用自适应霍夫曼压缩算法,正如 Khalid Sayood 的经典著作《数据压缩入门》(以及许多其他著作)中所述。但该算法真正以自适应模式运行的唯一部分是connect客户端在初始握手期间发送的命令。在“connect”字符串之后,数据包的其余部分包含用户信息结构,该结构使用自适应霍夫曼压缩进行传输,从一棵空树开始。当需要发送之前未传输过的符号时,会先发送(在尚未传输节点的代码之后)最低有效位。
对于已建立连接中发送的所有其他数据包,前四个字节(服务器到客户端数据包)或前六个字节(客户端到服务器数据包)之后的所有内容都使用相同的哈夫曼算法传输,但树保持不变,在每个符号传输后不会更新。所使用的树是使用硬编码频率表创建的,该表可能对应于游戏开发期间捕获的网络流量样本。
霍夫曼算法用于传输长度为 8 位的符号。当游戏需要发送非 8 位偶数倍的值时,它会首先按原样(未压缩)发送剩余的奇数位,从最低有效位开始。然后,它会将剩余的值以 8 位为单位进行霍夫曼压缩后发送。
服务器到客户端数据包
本节和下一节将使用一段希望不言自明的伪代码来描述实际的在线协议。结合对所使用的哈夫曼压缩和异或混淆的理解,以及实体和玩家状态结构体中字段的定义,应该足以实现该协议。
sequence (32 bits)
if (sequence=0xFFFFFFFF) {
command (ASCII, till the end of the packet)
} else {
// rest of the packet is Huffman-compressed
// it is also XOR-scrambled, except for the first 4 raw bytes
// of the Huffman-compressed region
reliable_acknowledge (32 bits)
repeat {
svc_op (8 bits)
if (svc_op = 2 (svc_gamestate)) {
last_client_command (32 bits) // that the server has received
repeat {
gamestate_op (8 bits)
if (gamestate_op = 3 (svc_configstring)) {
configstring_index (16 bits)
configstring (null-terminated ASCII)
}
if (gamestate_op = 4 (svc_baseline)) {
entity_number (10 bits)
update_or_delete (1 bit) (always 0)
entity_changed (1 bit)
if (entity_changed = 1) {
field_count (8 bits)
for i in (0 .. field_count-1) {
field_changed (1 bit)
if (field_changed = 1) {
// we need to know the entity field definitions
if (field i is of type float in the definition) {
float_is_not_zero (1 bit)
if (float_is_not_zero = 1) {
int_or_float (1 bit)
if (int_or_float = 0) {
float_as_int (13 bits)
}
if (int_or_float = 1) {
float_as_float (32 bits)
}
} else {
// field value is 0
}
} else {
int_is_not_zero (1 bit)
if (int_is_not_zero = 1) {
int_value (number of bits as in field i definition)
} else {
// field value is 0
}
}
}
}
}
}
} until (gamestate_op = 8 (svc_EOF))
client_number (32 bits)
checksum_feed (32 bits)
}
if (svc_op = 5 (svc_serverCommand)) {
command_sequence (32 bits)
command (null-terminated ASCII)
}
if (svc_op = 6 (svc_download)) {
block (16 bits)
if (block = 0) {
download_size (32 bits)
}
size (16 bits)
if (size > 0) {
data (size*8 bits)
}
}
if (svc_op = 7 (svc_snapshot)) {
server_time (32 bits)
delta_num (8 bits) // this snapshot is delta-compressed
// from delta_num snapshots ago
snap_flags (8 bits)
areamask_length (8 bits)
areamask (areamask_length*8 bits)
// playerstate:
field_count (8 bits)
for i in (0 .. field_count) {
field_changed (1 bit)
if (field_changed = 1) {
// we need to know the playerstate field definitions
if (field i is of type float in the definition) {
int_or_float (1 bit)
if (int_or_float = 0) {
float_as_int (13 bits)
}
if (int_or_float = 1) {
float_as_float (32 bits)
}
} else {
int_value (number of bits as in field i definition)
}
}
}
// arrays:
arrays_changed (1 bit)
if (arrays_changed = 1) {
stats_changed (1 bit)
if (stats_changed = 1) {
stats_bits (16 bits)
for i in (0..15) {
if (bit i is set in stats_bits) {
stats_bit_i (16 bits)
}
}
}
persistant_changed (1 bit)
if (persistant_changed = 1) {
persistant_bits (16 bits)
for i in (0..15) {
if (bit i is set in persistant_bits) {
persistant_bit_i (16 bits)
}
}
}
ammo_changed (1 bit)
if (ammo_changed = 1) {
ammo_bits (16 bits)
for i in (0..15) {
if (bit i is set in ammo_bits) {
ammo_bit_i (16 bits)
}
}
}
powerups_changed (1 bit)
if (powerups_changed = 1) {
powerups_bits (16 bits)
for i in (0..15) {
if (bit i is set in powerups_bits) {
powerups_bit_i (32 bits)
}
}
}
}
// entities:
repeat {
entity_number (10 bits)
if (entity_number != 1023) {
update_or_delete (1 bit)
if (update_or_delete = 0) {
entity_changed (1 bit)
if (entity_changed = 1) {
field_count (8 bits)
for i in (0 .. field_count-1) {
field_changed (1 bit)
if (field_changed = 1) {
// we need to know the entity field definitions
if (field i is of type float in the definition) {
float_is_not_zero (1 bit)
if (float_is_not_zero = 1) {
int_or_float (1 bit)
if (int_or_float = 0) {
float_as_int (13 bits)
}
if (int_or_float = 1) {
float_as_float (32 bits)
}
} else {
// field value is 0
}
} else {
int_is_not_zero (1 bit)
if (int_is_not_zero = 1) {
int_value (number of bits as in field i definition)
} else {
// field value is 0
}
}
}
}
}
}
if (update_or_delete = 1) {
// the entity is not present in the new snapshot
}
}
} until (entity_number = 1023)
}
} until (svc_op = 8 (svc_EOF))
}
客户端到服务器的数据包
sequence (32 bits)
if (sequence=0xFFFFFFFF) {
command (ASCII, till the end of the packet)
// as a special case, if the command starts with “connect ”, what follows
// is adaptive Huffman-compressed
} else {
qport (16 bits)
// from this point on, the packet is Huffman-compressed
// it is also XOR-scrambled, except for the first 12 raw bytes
// of the Huffman-compressed region
server_id (32 bits)
server_message_sequence (32 bits) // last message the client has received
server_command_sequence (32 bits) // last command the client has received
repeat {
clc_op (8 bits)
if (clc_op = 4 (clc_clientCommand)) {
command_sequence (32 bits)
command (null-terminated ASCII)
}
if (clc_op = 2 (clc_move) or clc_op = 3 (clc_moveNoDelta)) {
command_count (8 bits)
repeat (command_count times) {
server_time_relative (1 bit)
if (server_time_relative = 0) {
server_time (32 bits)
}
if (server_time_relative = 1) {
server_time_delta (8 bits)
// server_time = previous_server_time + server_time_delta
}
command_changed (1 bit)
if (command_changed = 1) {
// the values of the following fields are XOR-scrambled
angles0_changed (1 bit)
if (angles0_changed = 1) {
angles0 (16 bits)
}
angles1_changed (1 bit)
if (angles1_changed = 1) {
angles1 (16 bits)
}
angles2_changed (1 bit)
if (angles2_changed = 1) {
angles2 (16 bits)
}
forwardmove_changed (1 bit)
if (forwardmove_changed = 1) {
forwardmove (8 bits)
}
rightmove_changed (1 bit)
if (rightmove_changed = 1) {
rightmove (8 bits)
}
upmove_changed (1 bit)
if (upmove_changed = 1) {
upmove (8 bits)
}
buttons_changed (1 bit)
if (buttons_changed = 1) {
buttons (16 bits)
}
weapon_changed (1 bit)
if (weapon_changed = 1) {
weapon (8 bits)
}
}
}
}
} until (clc_op = 5 (clc_EOF))
}
片段
当 Quake 3 需要发送长度为 1300 字节或更长的数据包时,它会将其分成多个部分(称为“分片”),这些部分会分别发送,然后在另一端重新组装,然后再进行进一步处理。实际上,这种情况只会在客户端连接到服务器后或加载新地图时发送游戏状态(所有对象的起始位置和一些配置字符串)时发生。分片数据包的格式如下:
sequence (32 bits) // sequence number of the original packet with the
// most-significant bit set to indicate fragmentation
qport (16 bits) // only if client-to-server packet
fragment_offset (16 bits) // where in the original packet this fragment fits
fragment_length (16 bits)
data (fragment_length*8 bits)
片段长度为 1300 意味着会有更多的片段到来,因此如果原始数据包的长度是 1300 的偶数倍,那么最后会发送一个零长度的片段,以便另一端知道这是数据包的结尾。
初次握手
当客户端首次连接到服务器时,它们会交换某些消息,然后连接才算建立。此外,还有一个第三方,即授权服务器,由游戏开发者维护。它的作用是通过检查客户端提供的CD Key来验证玩家是否正在使用正版游戏。因此,客户端在getchallenge向服务器发送消息的同时,也会getKeyAuthorize向中央授权服务器发送一条消息,其中包含唯一的CD Key。当(游戏)服务器收到getchallenge来自客户端的消息时,它会getIpAuthorize向授权服务器发送一条消息,询问是否允许客户端进行游戏。如果它收到授权服务器的肯定响应,或者在一定时限内未收到任何答复,它会challengeResponse向客户端发送一条包含挑战码的消息。客户端会将该挑战码以及其他某些信息(例如协议版本)放入下一条消息中connect。然后,服务器会回复一条connectResponse消息,连接即建立。到目前为止,所有交换的数据包都是无连接的,这意味着它们的前四个字节都是 0xFF,并且没有经过异或扰码或霍夫曼压缩(connect数据包中的用户信息部分除外)。后续数据包以其序列号开头,并遵循常规数据包结构。
XOR密钥
对于正在进行的连接中的服务器到客户端数据包,用于混淆的 XOR 密钥源自在连接开始时交换的质询值、此特定数据包的序列号以及最后确认的客户端命令文本(其序列号是每个数据包中在 Huffman 压缩部分中发送的第一件事)。
第 i个字节的精确值(从 XOR 加扰区域的开头开始计数,从零开始)计算如下:
key = challenge xor sequence xor (last_command[i mod command_length] * (1+(i mod 2)))
除非命令文本中的第 n 个字符是“%”或者超出 7 位 ASCII 范围,否则将使用“.”的值。
对于客户端到服务器的数据包,XOR 密钥来自质询、作为每条消息中的第一项发送的值、作为下一项发送的最后确认的服务器数据包序列号以及最后确认的服务器命令的文本,第 iserver_id个字节的确切值计算如下:
key = challenge xor server_id xor server_message_sequence xor (last_command[i mod command_length] * (1+(i mod 2)))
消息文本的相同例外情况也适用。
除了这种处理之外,客户端作为用户命令的一部分发送的值也会再次进行混淆,使用相同的异或运算,但使用不同的密钥(这次操作在客户端写入时在哈夫曼压缩之前进行,在服务器读取时在解压之后进行)。所使用的密钥源自checksum_feed服务器发送的游戏状态值、上次确认的服务器数据包序列号、上次确认的服务器命令文本的哈希值以及server_time每个用户命令的值。
key = checksum_feed xor server_message_sequence xor last_command_hash xor server_time
最后一条命令的哈希值定义如下。首先计算一个 32 位的和:
partial_hash = sum for i=0..n-1 (last_command[i] * (119+i))
在哪里n = min(last_command_length, 32)
然后是最后的哈希值:
hash = partial_hash xor (partial_hash shr 10) xor (partial_hash shr 20)
控制台命令和变量
《雷神之锤》系列游戏的众多创新之处之一是引入了控制台。控制台可以通过按下波浪号 (~) 键激活,它是游戏内部的命令行界面,类似于大多数现代桌面操作系统中的命令提示符。除了可以从常规游戏界面访问的设置和功能外,它还允许对游戏进行广泛的自定义,并提供了一些调试功能,其中许多功能适用于网络组件。
与许多 Unix shell 一样,Quake 3 的控制台具有制表符补全功能,这意味着如果我们键入命令的开头并按下 Tab 键,它将完成该命令或显示以此前缀开头的所有命令(如果有多个)。
除了执行命令之外,还可以设置控制台变量(简称cvars)。cmdlist 命令显示所有命令的列表,而 cvarlist 命令显示所有变量的列表。以下列出了一些控制台变量,它们允许我们修改之前讨论过的一些行为。
cl_packetdup- 用户命令在后续数据包中重复的次数(有意义的值:0-5,默认值:1)
cl_maxpackets- 客户端每秒最多发送多少个数据包(有意义的值:15-125,默认值:30)
com_maxfps- 客户端每秒最多渲染多少帧(默认值:85)
sv_fps- 服务器每秒将处理多少帧(默认值:20)
vm_game,,vm_cgamevm_ui- VM 将运行什么类型的代码(0=本机,1=解释字节码,2=翻译字节码,默认值:2)
snaps- 客户端每秒希望接收多少个快照(默认值:20)
rate- 客户端每秒最多希望接收多少字节(默认值:3000)
sv_maxRate- 服务器端对客户端速率的限制(有意义的值:>=1000 或 0,默认值:0,表示无限制)
cl_timeNudge- 在快照之间进行插值/外推时偏移客户端的时间视图,正值表示更大的延迟,但需要外推的可能性更低,负值表示相反(有意义的值:-30 到 30,默认值:0)
showpackets- 如果非零,则打印有关发送和接收数据包的信息 - 它们的大小和序列号(默认值:0)
cg_lagometer- 如果非零,则启用拉格计(默认值:1)
cg_nopredict- 禁用客户端预测(默认值:0)
cg_predictItems- 启用客户端对物品拾取的预测(默认值:1)
cl_shownet- 如果非零,则打印有关快照中收到的增量压缩实体和玩家状态的调试信息(有意义的值:-2、-1、0、1、2、3、4,默认值:0)
journal- 0:正常操作,1:将所有事件写入 journal.dat 文件,2:从 journal.dat 文件读取事件,忽略正常输入(默认值:0;需要从命令行设置,例如
+set journal 1)
拉戈米特
网络连接对于《Quake 3》的游戏体验至关重要,游戏内置了一个诊断界面——延迟计,该界面默认启用。延迟计显示在屏幕右下角,由两个图表组成。它们上下重叠,但 X 轴相互独立,通常以不同的速度移动。
顶部图表每渲染一帧,客户端就会移动一个像素。蓝色表示在两个快照之间插值的帧,黄色表示必须外推的帧。Y 轴表示客户端帧时间与游戏外推或内推的快照时间之间的差值。
底部图表每从服务器接收(或未接收)一个快照,移动一个像素。绿色和黄色表示正确接收的快照,黄色还表示由于客户端请求的带宽限制(服务器通过设置 snap_flags 中的某个位来传达此信息),服务器故意未发送上一个快照。Y 轴表示 ping 值,即客户端和服务器之间的往返时间。垂直的红色条表示快照在网络上丢失(客户端知道这一点,因为数据包带有序列号)。
下面展示了两个示例计。左侧的计子表示连接良好,ping 值为 20 毫秒,无丢包,客户端速率设置为 25000。右侧的计子表示连接较差,ping 值为 150 毫秒,丢包率为 10%,客户端速率设置为 3000。
总结
希望本文至少能帮助您对 Quake 3 的网络架构有一个基本的了解,并鼓励您进行一些实验和代码分析。该引擎还有很多有趣的方面我们在这里没有讨论,例如渲染器或机器人系统。它的源代码绝对值得一看。






浙公网安备 33010602011771号