打开APP
userphoto
未登录

开通VIP,畅享免费电子书等14项超值服

开通VIP
网络游戏程序员须知 调试多人联机游戏

本文为作者原创或翻译,转载请注明,不得用于商业用途。

作者:rellikt@gmail.com

首发链接:http://blog.csdn.net/rellikt/archive/2010/09/23/5902745.aspx

简介    

欢迎大家再次来到我的博客,上次的博客简单的讲了一下关于可靠传输和流量控制的内容。这周我想再谈一下怎么调试联机对战游戏。

作为一个网络游戏程序员,我已经在好几个网络游戏中担任过开发任务了。现在回想起来,当我第一次将一个单机版游戏做成联机版的时候,在我面前的第一个难题就是如何进行调试。这玩意比起单机版的着实是不容易调试。

在后面的工作经历中,我又在不同的公司不同的组里开发联机模块,当我和我的同事们谈起联机模块的调试时,我发现这块事实上有很多不同的技术,但是这些技术似乎从来没有被系统的整理过,也没有文档化,当然几乎没有一家公司会全面的使用这些技术了。

所以我想写这篇联机对战的调试教程还是很有必要的。希望这里提到的那些小技巧也许会对大家当前的工程起到帮助。 by rellikt

同步调试

说起调试,单机版游戏的调试技巧我想大家已经很熟练了。我们可以在屏幕或者控制台输出调试信息,在log文件中导出调试信息,在IDE中设置断点,条件断点,数据断点,然后单步调试。但是在联机调试中我们能这么做吗?

事实上一旦我们这么做,其他的机器很快就会因为连接超时而断开连接,我们的调试也就无功而返了,这点的确是很恼人的。事实上我们要调试的情况就是有多台机器在同时异步的跑同一个程序,一旦我们在一台机器上进入断点,其他机器就会很快发现连接超时,然后断开连接,通常这个时间不会超过30秒,要在那么短的时间内调试bug简直是不可能的。

怎么解决这个问题呢?你首先想到的肯定是在两台机器上同时打断点,进入单步调试。这种技巧在2人联机的时候也许是可行的。但是当人数更多时,我们就会发现这个方法完全不给力了。那怎么办呢?我们需要同步调试来模拟我们在单人游戏时的情况。

同步调试的基本概念就是,我们会在每帧广播一个用来维持连接的脉动包到其他机器,然后在每个机器上都针对每个连接建立一个时间累计器,每次收到脉动包的时候,我们把对应连接的时间累计器清零,当我们发现某个时间累积器值超过一定范围的时候,我们自动阻塞进程。以下是一个大致的实现:

float timeSinceLastHeartbeat[MaxPlayers] = { 0.0f, ... };
while ( true )
{
    const float deltaTime = GetFrameTime();
    bool waiting = true;
    while ( waiting )
    {
        SendHeartbeatPacket();
        while ( true )
        {
            int fromPlayerId = -1;    
            int packetSize = 0;
            unsigned char packet[1024];
            if ( !RecievePacket( fromPlayerId, packet, packetSize ) )
                break;
            if ( IsGamePacket() )
                ProcessGamePacket( fromPlayerId, packet, packetSize );
            else if ( IsHeartbeatPacket() )
                timeSinceLastHeartbeat[fromPlayerId] = 0.0f;
        }
        waiting = false;
        for ( int i = 0; i < MaxPlayers; ++i )
        {
            if ( timeSinceLastHeartbeat[i] > 0.1f && !IsLocalPlayer(i) )
            {
                waiting = true; break;
            }
        }
    }
    SendGamePacket();
    GameUpdate( deltaTime );
    for ( int i = 0; i < MaxPlayers; ++i )
        timeSinceLastHeartbeat[i] += deltaTime;
}

这里我们要注意,即使是在等待脉动包的时候,我们还是会不停的发自己的脉动包,因为如果不这么做,就会产生嵌套死锁的等待,这样的话就再也回不来了。by rellikt

好了,我们把这段代码加入到工程里面以后,我们就会发现一旦我们在一台机器上进入断点,其他机器也会在0.1秒以后停下来,然后我们在继续当前机器的进程以后,其他机器也会继续。这样我们的联机调试就和单机调试很像了。

当然这里展示的只是一个简陋的版本,比如当有一台机器的帧率小于10帧的时候,其他机器也会慢,当一台机器死机的时候,其他机器也会死机。我们在实际工程中要对这些情况进行完善和维护,例如加入一些宏等来开关这个特性,这里不做太多的拓展了,大家因地制宜的自己去实现吧。

导出日志文件

如果你是职业游戏程序员并且在正规公司内工作过,那么你肯定已经接触过这方面的内容了。至少你需要在游戏正式运行版崩溃的时候能够拿到一个关于指令堆栈的日志文件,这样你才能知道从哪里开始解决问题。在屏幕上或者日志文件中同时做输出有时候是很有效的办法,至少当GD发现问题的时候,你能从他的屏幕上一目了然的知道一些问题的线索。

事实上怎么在不同的平台上输出日志文件和堆栈信息是很专业的问题,我这里也不可能展开太多。不过鉴于大多数游戏都会开发win32版本,如果你想在windows上做这个可以参考这篇文章,mac平台的话,我们一般可以直接用它自带的崩溃日志,事实上类unix的系统都会带有core dump这个功能,你可以把这些导出的文件用GDB进行调试,不过一般到这步都需要比较强的调试能力了。当然你也可以订制自己的单元测试模块,把需要的信息和相关的自定义assert宏关联起来输出,这也是一个不错的主意。

我们还有一个不错的主意就是建立一个自定义的信息堆栈。这个技巧在内存调试的时候也会经常用到,我们的基本思路就是在代码进出程序堆栈的时候给代码段打上一个自制的tag,这样在出错的时候,我们就可以在调用堆栈信息之外再拿到一个自定义的tag信息,而这个tag信息往往更为有用,这个tag可以记录文件名,模块名,包名等。by rellikt

下面是我简单做的一个实现:

 

const char MaxInfoLength = 256;
const char MaxInfoDepth = 64;
static int g_infoStackDepth = 0;
static char g_infoStack[MaxInfoDepth][MaxInfoLength + 1];

void push_info_stack( const char * string )
{
    assert( g_infoStackDepth < MaxInfoDepth );
    strncpy( &g_infoStack[g_infoStackDepth][0], string, MaxInfoLength );
    g_infoStack[g_infoStackDepth][MaxInfoLength] = '';
    g_infoStackDepth++;
}
void pop_info_stack()
{
    assert( g_infoStackDepth > 0 );
    g_infoStack[g_infoStackDepth][0] = '';
    g_infoStackDepth--;
}

class InfoPushPopHelper
{
    InfoPushPopHelper( const char * string )
    {
        push_info_stack( string );
    }
    ~InfoPushPopHelper()
    {
        pop_info_stack();
    }
};

#define INFO( format, ... ) /
char buffer[MaxInfoLength+1]; /
snprintf( buffer, MaxInfoLength+1, format, __VA_ARGS__ ); /
InfoPushPopHelper infoPushPop( buffer );

 

注意到我们这里写的那个helper类,这个类的存在使我们在代码段压栈弹栈的时候可以打上合适的tag,进行简单的信息记录。全局的infostack则可以在我们进入断点的时候给我们提供额外的信息。

 

void processWad( const char wadFilename[] )
{
    INFO( "processing wad: %s", wadFilename );
    WadLoader loader( wadFilename );
    while ( true )
    {
        WadResource * resource = loader.GetNextResource();
        if ( !resource ) break;
        switch ( resource->GetType() )
        {
        case WadResource::Image: processImage( resource ); break;
        case WadResource::Mesh: processMesh( resource ); break;
        // etc...
        }
    }
}

 

void processImage( WadResource * resource )
{
    INFO( "processing image: %s", resource->GetFileName());
    //etc…
}

以上这段代码大致为我们展示了怎么使用这个额外的信息堆栈,这端代码可以让我们得到是哪个文件的载入使我们崩溃的信息。我们只要简单的把我们每个收发的包的信息填入,就可以得到一个联机调试的额外信息堆栈了。事实上编写一些有趣的字符串来描述整个游戏过程是很有趣的,至少比那些单调的调用堆栈信息有趣。你可以写类似“我A了一下飞龙,然后游戏崩溃。”这样的日志,是不是觉得很酷啊?by rellikt

现在你有一个很好的同步调试机制和额外日志系统了,接下来就是邀请你的同事在休息的时候陪你一起做测试了。当然他们有可能不是那么愿意,那么你只好编写一些AI bot来帮你完成这个任务了,事实上网络调试是整个游戏调试中最繁琐的部分,要做各种压力测试,环境测试。因此一个良好的日志系统和高质量的崩溃报告绝对是物有所值的,当然能够混合单元测试和功能测试那就更有效了。

过程记录和回档

光有以上这些技巧有时候还是不够的。有些时候我们遇到的bug可能在好多帧前面就已经发生了,只是在这帧才碰巧爆发,因此面对完全正常的错误日志,你往往会一筹莫展。又有时候,我们的堆栈信息已经被污染了,因此我们得到的仅仅是一些垃圾信息,完全的于事无补。更绝的是,我们有时候还会碰到一些不定发生的bug,他们可能调试几周才能遇上一次,或者在本地无法重现,在测试那边就很容易发生。以上这些bug往往会令你完全的泪流满面,不知所措。

那么我们有什么好的办法来应对这些疑难杂症吗?我们一般遇到这些bug的时候,往往只能简单的给程序加上更多的log标签或者assert信息,希望测试在再次遇到这个bug的时候,我们加上的这些标签能够带来有用的信息,当然如果他们无法带来有用的信息,我们一般只能继续加,直到幸运女神光临我们的一天。

那么我们是不是可以有更好的办法呢?比如我们可以记录下来玩家的操作信息,然后当他们遇到这个bug的时候,我们就可以把记录日志导出,然后用这个日志记录的操作去重现这个bug,这是不是会好点的。事实上我们有时候就是这么做的。我们称这种技术叫全程回放。基本的思路就是把游戏中所有的不确定因素记录下来,然后把它们作为替代在游戏重演的时候加入进去。这样我们就可以在调试工具中完整重现我们要的游戏过程了。by rellikt

我们一般记录的内容会是每次的输入,每个随机数的种子,每帧的时间,每个包的内容等。事实上如果你能采用这个这个技术,那么你不需要任何输入和联网就能完成一个多人对战游戏的全过程,事实上这才是真正我们想要的调试。

来看一个典型的多人对战游戏的模型:

while ( true )
{
    SendPackets( socket );
    while ( true )
    {
        int packetSize = 0;
        unsigned char packet[1024];
        if ( !socket.RecievePacket( packet, packetSize ) ) break;
        assert( packetSize > 0 );
        Game::ProcessPacket( packet, packetSize );
    }
    float frameTime = Timer::getFrameTime();
    Game::Update( frameTime );
}

现在让我们看看怎么可以加入全程回放技术。我们这里在做记录和回放时用到的技术是不一样的。在做记录的时候,我们需要把不同的数值写入记录文件,然而在回放的时候,我们需要把记录文件中的值读出然后取代不同的数值。by rellikt

void journal_int( int & value );
void journal_bool( bool & value );
void journal_float( float & value );
void journal_bytes( unsigned char * data, int & size );
bool journal_playback();
bool journal_recording();

下面是实际中的一个简单应用:

while ( true )
{
    SendPackets( socket );
    while ( true )
    {
        int packetSize = 0;
        unsigned char packet[1024];
        bool result = false;
        if ( !journal_playback() )
            result = socket.RecievePacket( packet, packetSize );
        journal_bool( result );
        if ( !result ) break;
            journal_bytes( packet, packetSize );
        assert( packetSize > 0 );
        Game::ProcessPacket( packet, packetSize );
    }
    float frameTime = Timer::getFrameTime();
    journal_float( frameTime );
    Game::Update( frameTime );
}

注意这里我们在检测到play_back()为true的时候会屏蔽掉接收包的函数,用我们自己从记录文件中读取到的内容做代替,这就是我们这里可以不启用网络就可以做联机调试的本质原因。

你一定意识到了全程回放技巧的价值,我们现在可以重演游戏直到发生bug的位置。甚至我们可以在修复bug以后再次用那个会产生死机的操作来验证我们的修复是否有效。不用怀疑,全程回放肯定是调试多人联机游戏的终极利器,但是要启用这个终极兵器,我们要付出的代价当然也是极大的。维护成本肯定是相当高的,我们必须记录下所有的不确定因素,一旦中间有任何差错,你就必须慢慢的回溯直到找到差错的地方。任何做过网络开发的程序员肯定能明白这个过程是多么的痛苦。

另外一个痛苦的地方就是对于多线程的情况,有时候你根本无法对多线程的游戏进行合适的全程回放。一般的情况是你可以把所有的包含不确定因素的代码放到一些辅助线程中,然后把那些确定性好的代码加入主线程。然后在进程通信完毕的时候做记录或者替换。同样在处理异步的输入和输出的时候,你也可以用这个办法。但是要注意,你加入的全程回放代码可能会导致一些性能的下降,然后如果在代码结构发生变化的时候,每次维护这个系统也是一件苦差事。 by rellikt

事实现在多数公司都不会在底层自己去实现这个系统了,他们会花钱购买一些自动化测试工具来做系统层面上的全程回放,除非游戏有录像功能的要求,否则这个系统的维护是相当费时费力的。我以前用过一个叫Replay Director的工具,不过现在好像这个工具已经放弃对PC或360游戏的支持了,现在它只支持java程序。

结论

作为终极兵器,这个系统的确有其价值,当然也充满了坎坷,如果你想在自己的工程中尝试,那么首先要记得把所有的不确定因素放到一个主线程中,这样才好做模拟。当然如果你懒得去实现的话,我想前面介绍的同步调试技巧和额外日志文件技巧就已经很好用了。

本站仅提供存储服务,所有内容均由用户发布,如发现有害或侵权内容,请点击举报
打开APP,阅读全文并永久保存 查看更多类似文章
猜你喜欢
类似文章
Mosquitto源码分析(一)mosquitto简介
修改srs服务器端支持h.265编码(rtmp协议)
FU-A分包方式,以及从RTP包里面得到H.264数据和AAC数据的方法
C++的逆波兰表达式的求解
arduino w5100网络模块双向通信
Asp.net编写的PING工具
更多类似文章 >>
生活服务
热点新闻
分享 收藏 导长图 关注 下载文章
绑定账号成功
后续可登录账号畅享VIP特权!
如果VIP功能使用有故障,
可点击这里联系客服!

联系客服