I. Introduction▲
Bonjour, et bienvenue dans la traduction française du cinquième article de la série d'articles « le réseau dans les jeux vidéo » écrits par Glenn Fiedler.
Glenn est un programmeur de jeu professionnel qui développe des jeux multijoueurs depuis plusieurs années maintenant, mais lorsqu'il est passé pour la première fois d'un jeu solo à un jeu multi, la première chose qu'il a remarquée fut « oh crotte, c'est soudainement très difficile de déboguer quoi que ce soit ».
Il a désormais travaillé avec trois équipes de développements sur des jeux multijoueurs, et discuté avec de nombreux programmeurs sur plusieurs projets. Dès que la discussion traite de débogage, il semble y avoir des techniques communes utilisées par les équipes multijoueurs, mais 1) il ne semble y avoir aucune documentation où que ce soit à propos de ces techniques, et 2) il est rare qu'une équipe utilise toutes les techniques disponibles.
Il a donc pensé que ce serait utile d'écrire un article à propos du débogage d'un jeu multijoueur, espérant ainsi vous fournir des idées et techniques utilisables dans vos propres projets.
II. Débogage synchrone▲
Déboguer des jeux multijoueurs, ce n'est pas si difficile, n'est-ce pas ? Je vais juste lancer le jeu avec le débogueur et mettre un point d'arrêt… euh, attends… pourquoi l'autre machine se déconnecte-t-elle ? Zut.
Ceci résume plutôt bien mes premiers essais de débogage de jeux multijoueurs.
Le problème est que vous avez deux ou plus processus asynchrones exécutés sur différentes machines. Si vous vous arrêtez dans le processus de jeu sur une machine, votre connexion va expirer avec les autres joueurs. Je ne sais pas pour vous, mais je trouve le débogage assez difficile sans se limiter à 30 secondes pour trouver ce qui ne va pas.
Vous pourriez essayer de vous arrêter manuellement dans plusieurs instances du jeu simultanément avec le débogueur, mais c'est vraiment fastidieux avec juste deux joueurs alors imaginez avec plus de joueurs. Une meilleure option est d'ajouter un mode débogage synchrone qui imite le comportement du débogueur attendu en jeu solo.
L'astuce est d'envoyer un paquet « heartbeat » à chaque frame, puis sur chaque machine garder un compte du temps écoulé depuis le dernier heartbeat reçu de chaque joueur. Si un des compteurs excède une certaine valeur - 1/100 s par exemple -, vous bloquez jusqu'à recevoir un heartbeat de ce joueur.
Le secret est que les paquets heartbeat sont envoyés en continu pendant l'attente d'un heartbeat. Ceci est très important. Vous devez faire ainsi, sinon un joueur qui attend un joueur entraînera l'attente d'un autre joueur, etc. pour finir par un deadlock !
Avec cette technique, vous devriez remarquer que s'arrêter sur une machine entraîne la pause des autres 1/10 s plus tard. Continuez l'exécution et le jeu reprendra pour tous les joueurs. Ça ressemble presque à un jeu solo.
Ouf ! Nous pouvons reprendre le débogage. Enfin, presque…
Comme toutes les techniques elle a des limites : si un joueur a un freeze et un fps qui monte à plus de 1/10 s, cela se répercutera chez tous les joueurs de la partie, et si un joueur « crashe » ou s'arrête sur une assertion, toute la session de jeu est foutue. Vous ne voudrez sûrement pas activer cette option par défaut. Ajoutez juste un flag de débogage, ou une commande pour la (dés)activer à tout moment.
III. Crash dumps et pile d'informations▲
En tant que programmeurs, quand on lance le jeu nous pouvons mettre un point d'arrêt et inspecter ce qui nous intéresse avec le débogueur. Le problème est, lorsque d'autres personnes comme les designers ou artistes lancent le jeu, il s'agit généralement d'une version compilée. Ils n'ont même pas forcément les outils de développement installés sur leur machine.
À ce moment, il est essentiel d'avoir un bon outil de rapport de crash.
Si vous travaillez dans un studio un peu important, vous avez probablement déjà quelque chose comme ça. Vous aurez besoin au minimum de connaître la version de l'exécutable et la pile d'appels quand le jeu crashe, afin d'avoir un point de départ pour commencer le débogage sur votre machine, et c'est une assez bonne idée d'afficher le rapport de crash dans le terminal et la fenêtre de jeu pour comprendre d'un coup d'œil rapide ce qui s'est passé en regardant la machine du designer.
Les détails pour développer votre propre outil de rapport de crash sont dépendants de la plateforme et méritent un article à part entière. Mais si vous développez sur PC voici un bon point de départ http://msinilo.pl/blog/?p=269. Les utilisateurs de MAC OS ont la chance d'avoir un bon outil intégré au système http://en.wikipedia.org/wiki/Crash_Reporter_(Mac_OS_X), et bien sûr les systèmes Unix produisent des core dumps (http://en.wikipedia.org/wiki/Core_dump) qui peuvent être débogués en utilisant GDB. C'est également une bonne pratique de développer votre propre macro d'assertion http://cnicholson.net/2009/02/stupid-c-tricks-adventures-in-assert/ et la brancher sur votre outil de rapport de crash.
Une amélioration très simple de votre outil de rapport de crash est l'ajout d'une pile d'infos. Une pile d'infos est juste une pile de chaînes de caractères définies par l'utilisateur qui s'empilent et se dépilent quand on entre et sort d'une fonction de votre jeu. L'idée est de fournir un contexte additionnel quand vous crashez, sans avoir à logger chaque opération pendant que le jeu tourne normalement.
Maintenant au lieu d'avoir juste une pile d'appels qui vous indique que le crash a eu lieu dans une sous-fonction pendant le traitement de l'image X, où X est le nom du fichier image, quand vous déboguerez des problèmes venant d'un designer ou artiste, ou bien dans notre cas - de l'arrivée d'un paquet réseau - vous aurez un contexte et une pile d'infos qui vaut son pesant d'or.
Tout ceci peut ne pas vouloir dire grand-chose, mais quand vous avez un rapport de crash détaillé et une pile d'infos tout change : au lieu de regarder avec effroi un flux sans fin de rapports de bogue « le jeu a crashé quand je faisais X », vous pouvez étudier chaque lancement du jeu minutieusement pour fixer le problème. C'en est presque drôle. Vraiment. OK. Peut-être que ça devient vraiment ennuyant au bout d'un moment, mais je vous promets que c'est marrant au moins pour les premiers crashs.
Maintenant que vous avez des rapports de crash de qualité, il est habituel de mettre en place des parties multijoueurs avec des paramètres de base dans l'après-midi pour que votre équipe teste les dernières versions… et les crashe.
Une autre bonne idée est la mise en place de bots clients pour stress-tester (http://en.wikipedia.org/wiki/Stress_testing_(software)) votre code gameplay de - nuit - idéalement pour débusquer tous les crashs et instabilités ajoutés la veille.
Pensez-y, si un designer rencontre un bogue ou une erreur dans votre code multijoueur - bien que vous pouvez le corriger, mais en un sens vous avez déjà échoué. C'est bien mieux de détecter cette erreur plus tôt et la corriger avant que quiconque la subisse. Les tests automatiques combinés avec un rapport de crash de bonne qualité sont une façon d'atteindre cet objectif, les tests unitaires et fonctionnels en sont une autre.
IV. Replays▲
Les crash dumps sont excellents, mais parfois, quoi que vous essayiez, ils ne fournissent juste pas assez d'informations pour trouver la source du problème.
Peut-être que l'erreur s'est propagée sur quelques frames et le crash est juste le résultat de ce qui s'est mal passé deux frames plus tôt. Parfois vous regardez le rapport de crash et pensez en vous-même « il n'y a absolument aucun moyen que ça se produise ! »
D'autres fois la pile est corrompue et le rapport de crash est juste un amas de données inutiles. Et vous avez ces erreurs terribles, difficiles à reproduire : celles qui arrivent si rarement que vous ne la voyez qu'une fois toutes les quelques semaines, et là, pire de tout, quoi que vous essayiez, vous ne parvenez pas à la reproduire sur votre machine.
Comment pouvez-vous fixer ce genre de bogue ?
L'approche classique est d'émettre une hypothèse : ajoutez quelques logs et assertions, faites une nouvelle version et attendez quelques jours que quelqu'un d'autre reproduise le problème. Si votre hypothèse est bonne alors vous avez plus d'informations dans le rapport de bogue. Si elle était fausse, recommencez le processus en ajoutant plus d'assertions et logs. Avec un peu de matière grise et de chance, vous devriez converger vers le bogue, mais ça peut prendre beaucoup de temps et de va-et-vient.
Ne serait-ce pas top de plutôt pouvoir enregistrer la session de jeu de quelqu'un jusqu'à ce qu'il rencontre le bogue, puis de pouvoir rejouer cette session dans votre débogueur ?
Il se trouve que vous le pouvez.
Il s'agit d'un enregistrement. L'idée est d'enregistrer toutes les sources de comportement non déterministe, puis pendant le replay injectez les valeurs enregistrées in-game, ainsi la session de jeu se joue à l'identique dans votre débogueur.
Typiquement, vous enregistrez les trucs comme les entrées utilisateur, graines de générateurs d'aléatoire et codes retours des API - vous pouvez même enregistrer les paquets envoyés et reçus. En utilisant cette technique, vous pouvez faire un replay d'un client ou serveur dans un jeu 32 joueurs, puis le rejouer dans le débogueur, sans réseau nécessaire. Ça, c'est du débogage !
Regardez cette boucle principale classique d'un jeu multijoueur :
while
(
true
)
{
SendPackets
(
);
while
(
true
)
{
int
packetSize =
0
;
unsigned
char
packet[1024
];
if
(!
ReceivePacket
(
packet,
packetSize))
break
;
assert
(
packetSize >
0
);
ProcessPacket
(
packet,
packetSize);
}
float
frameTime =
Timer::getFrameTime
(
);
GameUpdate
(
frameTime);
}
Ajoutons maintenant quelques fonctions d'enregistrement. Notez que ces fonctions opèrent différemment en mode enregistrement ou replay. En mode enregistrement, les paramètres sont enregistrés dans le fichier de replay. En mode replay les données sont lues depuis le fichier et mettent à jour les paramètres avec leurs valeurs.
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
(
);
Et voici comment utiliser ces fonctions pour enregistrer notre jeu :
while
(
true
)
{
SendPackets
(
);
while
(
true
)
{
int
packetSize =
0
;
unsigned
char
packet[1024
];
bool result =
false
;
if
(!
journal_playback
(
))
result =
ReceivePacket
(
packet,
packetSize);
journal_bool
(
result);
if
(!
result)
break
;
journal_bytes
(
packet, packetSize);
assert
(
packetSize >
0
);
ProcessPacket
(
packet, packetSize);
}
float
frameTime =
Timer::getFrameTime
(
);
journal_float
(
frameTime);
GameUpdate
(
frameTime);
}
Remarquez que la fonction ReceivePacket n'est même pas appelée en mode replay. À la place, nous enregistrons son résultat et les données du paquet reçu. De cette manière, quand nous jouons le replay, nous rejouons l'exacte série de paquets reçus pendant l'enregistrement. Voilà comment un replay peut permettre de visionner une partie multijoueur dans le débogueur sans nécessiter de réseau.
Vous pouvez donc voir les valeurs de l'enregistrement. Vous pouvez rejouer la session de jeu dans le débogueur jusqu'à ce que le bogue se produise. Maintenant vous pouvez faire un truc grandiose, non seulement reproduire tout bogue que vous pouvez enregistrer, mais pas que, -si vous pouvez faire un petit changement pour corriger le problème, qui ne casse pas le déterminisme du replay, vous pouvez aussi le rejouer pour vérifier que le problème est corrigé.
Sans aucun doute, l'enregistrement est la technique de débogage la plus puissante pour un jeu multijoueur. Quand ça marche. Et malheureusement, ça peut ne pas durer. Ça peut demander beaucoup de maintenance. Pourquoi ? Et bien vous devez faire attention à garantir l'exactitude de la série de données lues et écrites pendant l'enregistrement et le replay, ou bien il se désynchronise.
En d'autres termes, vous devez vous assurer d'enregistrer chaque donnée non déterministe de votre programme dans l'ordre pour que le replay se joue correctement. Si vous ne le faites pas, vous n'aurez qu'un log de valeurs dans un fichier que vous devrez éplucher pour chercher et identifier à quel moment vous avez une mauvaise valeur qui apparait. Quiconque ayant déjà développé un jeu multijoueur basé sur un modèle réseau déterministe sait combien ça peut être frustrant et chronophage.
Une autre faiblesse de la journalisation est que si votre jeu est multithreadé, il peut être très difficile, voire impossible, de la réaliser correctement. Dans un environnement multithreadé vous pourriez essayer de garder tout le code déterministe dans un thread principal de simulation, exécuter les tâches non déterministes dans d'autres threads, et enregistrer leur résultat quand elles sont terminées. De même, pour les opérations de lecture et écriture asynchrones vous pouvez enregistrer chaque donnée quand elle est prête à l'utilisation, et pendant le replay bloquer jusqu'à ce qu'elle soit terminée. Mais notez que dans tous les cas, vous aurez quelques pertes de performances pendant le replay, et possiblement aussi pendant l'enregistrement, en plus ce ça c'est vraiment pénible à maintenir et à déboguer quand ça casse.
Donc la journalisation est une technologie extrêmement puissante et fantastique pour déboguer quand elle marche, mais opérez avec précaution ! Soyez sûr d'avoir toutes les opérations principales dans un seul thread et vous êtes sûr d'avoir une stratégie crédible pour enregistrer chaque système avant de vous engager dans cette voie.
V. Remerciements▲
Cet article est une traduction autorisée de l'article de Glenn Fiedler.
Merci aussi à Claude Leloup pour sa relecture orthographique.