Le réseau dans les jeux vidéo

Envoyer et recevoir des paquets

Glenn Fiedler

Traducteur

Bousk

Après avoir décidé d'utiliser UDP pour la communication sur le réseau de notre jeu, voyons comment envoyer et recevoir des paquets.

Commentez

- Titre : Le réseau dans les jeux vidéo

- Auteur : Glenn Fiedler

- Traducteur : Bousk

- Parution : 7 mai 2015

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2015 Glenn Fiedler. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Sockets BSD

- III. Spécificités de plateforme

- IV. Initialiser la couche socket

- V. Créer un socket

- VI. Mettre le socket en mode non bloquant

- VII. Envoyer des paquets

- VIII. Recevoir des paquets

- IX. Détruire un socket

- X. Une classe socket

- XI. Conclusion

- XII. Remerciements

I - Introduction

Bonjour, et bienvenue dans la traduction française du second article de la série d'articles « le réseau dans les jeux vidéo » écrits par Glenn Fiedler.

Dans l'article précédent, nous avons passé en revue les options pour partager des données entre des ordinateurs, et avons décidé d'utiliser UDP plutôt que TCP. Nous avons choisi UDP pour que nos données arrivent rapidement sans effet d'agglomération dans l'attente d'un renvoi de paquets.

Maintenant, je vais vous montrer comment on envoie et reçoit des paquets en UDP.

II - Sockets BSD

Pour la plupart des plateformes modernes vous avez une couche socket basique basée sur les sockets BSD.

Les sockets BSD sont manipulés avec de simples fonctions comme « socket », « bind », « sendto » et « recvfrom ». Bien sûr vous pouvez travailler directement avec ces fonctions si vous le souhaitez, mais il devient difficile de garder un code indépendant de la plateforme parce que chacune a ses spécificités.

Donc, je vais commencer par vous montrer les bases de l'utilisation de socket BSD avec des exemples de code, mais nous n'utiliserons pas bien longtemps les sockets BSD directement. À la place, dès que nous aurons traité les fonctionnalités de base des sockets, nous ajouterons un niveau d'abstraction grâce à des classes, pour rendre l'écriture de code indépendant de la plateforme.

III - Spécificités de plateforme

D'abord, initialisons un define pour qu'on puisse détecter la plateforme actuelle, afin de gérer les quelques différences dans la bibliothèque des sockets dépendant de la plateforme.

// détection de la plateforme

#define PLATFORM_WINDOWS 1
#define PLATFORM_MAC 2
#define PLATFORM_UNIX 3

#if defined(_WIN32)
#define PLATFORM PLATFORM_WINDOWS
#elif defined(__APPLE__)
#define PLATFORM PLATFORM_MAC
#else
#define PLATFORM PLATFORM_UNIX
#endif

Maintenant, nous pouvons inclure les fichiers d'en-tête appropriés pour chaque plateforme, grâce à l'utilisation de nos define.

#if PLATFORM == PLATFORM_WINDOWS

#include <winsock2.h>

#elif PLATFORM == PLATFORM_MAC || PLATFORM == PLATFORM_UNIX

#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>

#endif

Les sockets sont intégrés à la bibliothèque standard sur les systèmes Unix, il n'y a donc rien à lier. Par contre, sur Windows, nous devons lier la bibliothèque winsock pour utiliser les méthodes de la bibliothèque des sockets.

Voici une astuce simple pour y parvenir sans avoir à changer votre projet ou makefile :

#if PLATFORM == PLATFORM_WINDOWS
#pragma comment( lib, "wsock32.lib" )
#endif

J'adore cette astuce parce que je suis très paresseux. Bien sûr, vous pouvez toujours lier la bibliothèque dans les propriétés du projet ou votre makefile si vous le souhaitez.

IV - Initialiser la couche socket

La plupart des plateformes Unix (Mac OS X inclus) ne nécessitent aucune initialisation particulière pour utiliser les sockets, contrairement à Windows. Vous devez appeler « WSAStartup » pour initialiser la couche socket avant d'appeler la moindre fonction de la bibliothèque des sockets, puis « WSACleanup » pour l'arrêter quand vous n'en avez plus besoin.

Ajoutons deux nouvelles fonctions :

bool InitializeSockets()
{
#if PLATFORM == PLATFORM_WINDOWS
WSADATA WsaData;
return WSAStartup( MAKEWORD(2,2),  &WsaData )  == NO_ERROR;
#else
return true;
#endif
}
void ShutdownSockets()
{
#if PLATFORM == PLATFORM_WINDOWS
WSACleanup();
#endif
}

Maintenant, nous avons une méthode d'initialisation indépendante de la plateforme. Sur les plateformes qui ne nécessitent aucune initialisation, ces fonctions ne font rien.

V - Créer un socket

Il est temps de créer un socket UDP. Voici comment faire :

int handle = socket( AF_INET,  SOCK_DGRAM,  IPPROTO_UDP );
if ( handle <= 0 )
{
printf( "Erreur creation socket\n" );
return false;
}

Ensuite, nous lions le socket UDP à un port (par exemple le 30000). Chaque socket doit être lié à un port unique, parce que, quand un paquet arrive, le numéro de port détermine à quel socket il doit être acheminé. N'utilisez pas les ports inférieurs à 1024, ils sont réservés pour le système.

Cas particulier : si vous vous moquez du port que votre socket va utiliser, vous pouvez demander à lier le port « 0 » et le système en sélectionnera un libre pour vous.

sockaddr_in address;
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons( (unsigned short) port );
if ( bind( handle,  (const sockaddr*) &address,  sizeof(sockaddr_in) ) < 0 )
{
printf( "Erreur liaison port\n" );
return false;
}

Maintenant le socket est prêt à envoyer et recevoir des paquets.

Mais quelle est cette mystérieuse fonction « htons » dans le code ci-dessus ? Il s'agit d'une fonction d'aide pour convertir un entier 16 bits du boutisme (endianness) de l'hôte (petit ou gros-boutiste - little-endian ou big-endian) vers le boutisme du réseau (grand-boutiste). C'est nécessaire quand vous envoyez des entiers à travers le réseau.

Vous verrez « htons » (host to network short) et son cousin pour les entiers 32 bits « htonl » (host to network long) utilisés à plusieurs reprises dans cet article, donc restez attentif, et vous comprendrez ce qu'il se passe.

VI - Mettre le socket en mode non bloquant

Par défaut les sockets sont dans ce qu'on appelle un « mode bloquant ». Ça signifie que si vous essayez de lire un paquet avec la fonction « recvfrom », la fonction ne retournera pas tant qu'un paquet n'est pas disponible. Ce n'est pas du tout adapté à notre objectif. Les jeux vidéo sont des programmes temps réel qui simulent 30 à 60 images par seconde, ils ne peuvent pas se permettre d'attendre qu'un paquet arrive !

La solution est de passer le socket en « mode non bloquant » après l'avoir créé. Après ça, la fonction « recvfrom » retournera immédiatement quand aucun paquet n'est disponible, avec une valeur de retour qui indique que vous devriez essayer de lire un paquet plus tard.

Voici comment passer un socket en mode non bloquant :

#if PLATFORM == PLATFORM_MAC ||  PLATFORM == PLATFORM_UNIX
int nonBlocking = 1;
if ( fcntl( handle,  F_SETFL,  O_NONBLOCK,  nonBlocking ) == -1 )
{
printf( "Erreur parametrage non bloquant\n" );
return false;
}
#elif PLATFORM == PLATFORM_WINDOWS
DWORD nonBlocking = 1;
if ( ioctlsocket( handle,  FIONBIO,  &nonBlocking ) != 0 )
{
printf( "Erreur parametrage non bloquant\n" );
return false;
}
#endif

Comme vous pouvez le voir ci-dessus, Windows ne fournit pas de fonction « fcntl », donc nous utilisons « ioctlsocket ».

VII - Envoyer des paquets

UDP est un protocole non connecté, donc chaque fois que vous envoyez un paquet, vous devez préciser l'adresse de destination. Vous pouvez utiliser un socket UDP pour envoyer des paquets à autant d'adresses IP différentes que souhaité, il n'y a pas d'ordinateur de l'autre côté auquel votre socket UDP est connecté.

Voici comment envoyer un paquet à une adresse spécifique :

int sent_bytes = sendto( handle,  (const char*)packet_data,  packet_size, 0,  (sockaddr*)&address,  sizeof(sockaddr_in) );
if ( sent_bytes != packet_size )
{
printf( "Erreur envoi paquet\n" );
return false;
}

Attention ! La valeur de retour de « sendto » indique seulement si le paquet a été correctement envoyé depuis l'ordinateur local. Elle n'indique pas si le paquet a été reçu ou non par l'ordinateur distant ! UDP n'a aucun moyen de savoir si le paquet est arrivé à destination ou non.

Dans le code ci-dessus, nous passons une structure « sockaddr_in » comme adresse de destination. Comment devons initialiser cette structure ?

Disons que nous voulons envoyer à l'adresse 207.45.186.98:30000

Nous commençons avec cette adresse sous cette forme :

unsigned int a = 207;
unsigned int b = 45;
unsigned int c = 186;
unsigned int d = 98;
unsigned short port = 30000;

Nous avons un peu de travail à faire pour le convertir dans une forme acceptée par « sendto » :

unsigned int address = ( a << 24 ) |  ( b << 16 ) |  ( c << 8 ) |  d;
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl( address );
addr.sin_port = htons( port );

Comme vous pouvez le voir, nous commençons par associer les valeurs a,b,c,d dans la plage [0,255] en un seul entier, avec chaque octet de notre entier qui correspond à une valeur d'entrée. Ensuite nous initialisons une structure « sockaddr_in » avec notre adresse entière et le port, en s'assurant de les convertir dans le boutisme réseau avec les fonctions « htonl » et « htons ».

Cas particulier : si vous voulez envoyer un paquet à vous-même, pas besoin de demander l'adresse IP de votre machine, utilisez juste l'adresse de loopback 127.0.0.1 et le paquet sera envoyé à votre propre machine.

VIII - Recevoir des paquets

Une fois que vous avez un socket UDP lié à un port, tous les paquets UDP envoyés à votre socket, par votre IP et son port, sont mis dans une file. Pour recevoir un paquet, faites une simple boucle pour appeler « recvfrom » jusqu'à ce que la fonction indique une erreur indiquant que la file de paquets reçus est vide.

Étant donné qu'UDP est un protocole non connecté, les paquets peuvent arriver de nombreux ordinateurs. À chaque paquet reçu, « recvfrom » vous donne l'adresse IP et le port de l'expéditeur, donc vous savez d'où vient le paquet.

Voici comment récupérer tous les paquets reçus :

while ( true )
{
unsigned char packet_data[256];
unsigned int max_packet_size =  sizeof( packet_data );
#if PLATFORM == PLATFORM_WINDOWS
typedef int socklen_t;
#endif
sockaddr_in from;
socklen_t fromLength = sizeof( from );
int bytes = recvfrom( socket,  (char*)packet_data,  max_packet_size, 0,  (sockaddr*)&from,  &fromLength );
if ( bytes <= 0 )
break;
unsigned int from_address =  ntohl( from.sin_addr.s_addr );
unsigned int from_port =  ntohs( from.sin_port );
// traiter le paquet reçu
}

Les paquets dans la file dépassant le buffer de réception seront rejetés silencieusement. Donc si votre buffer fait 256 bits pour la réception, et que quelqu'un vous envoie un paquet de 300 bits, les 300 bits seront perdus. Vous ne recevrez pas juste les 256 premiers bits du paquet de 300.

Puisque vous écrivez votre propre protocole de jeu en réseau, ce n'est pas du tout un problème en pratique, assurez-vous juste que votre buffer de réception est assez grand pour récupérer le paquet le plus large que votre code puisse envoyer.

IX - Détruire un socket

Sur la plupart des plateformes Unix, les sockets sont des descripteurs de fichiers, vous pouvez donc utiliser la fonction « close » classique pour fermer un socket. Mais Windows aime se différencier, donc il faudra utiliser « closesocket » à la place :

#if PLATFORM == PLATFORM_MAC ||  PLATFORM == PLATFORM_UNIX
close( socket );
#elif PLATFORM == PLATFORM_WINDOWS
closesocket( socket );
#endif

Vive Windows !

X - Une classe socket

Nous avons couvert toutes les opérations de base : créer un socket, le lier à un port, le mettre non bloquant, envoyer et recevoir des paquets, puis fermer le socket.

Mais vous avez remarqué que la plupart de ces opérations sont quelque peu différentes selon la plateforme, et c'est plutôt gênant d'avoir à se souvenir de #ifdef et faire du code spécifique à la plateforme à chaque fois que vous voulez réaliser une opération sur votre socket.

Nous allons régler ce problème en enveloppant toutes les fonctionnalités de notre socket dans une classe « Socket ». Pendant qu'on y est, nous ajouterons une classe « Address » pour pouvoir manipuler aisément une adresse internet. Ça nous évitera d'avoir à manuellement encoder ou décoder une structure « sockaddr_in » à chaque envoi ou réception de paquets.

Voici à quoi notre classe Socket ressemble :

class Socket
{
public:
Socket();
~Socket();
bool Open( unsigned short port );
void Close();
bool IsOpen() const;
bool Send( const Address & destination,  const void * data,  int size );
int Receive( Address & sender,  void * data,  int size );
private:
int handle;
};

Et voici à quoi notre classe Address ressemble :

class Address
{
public:
Address();
Address( unsigned char a,  unsigned char b,  unsigned char c,  unsigned char d,  unsigned short port );
Address( unsigned int address,  unsigned short port );
unsigned int GetAddress() const;
unsigned char GetA() const;
unsigned char GetB() const;
unsigned char GetC() const;
unsigned char GetD() const;
unsigned short GetPort() const;
private:
unsigned int address;
unsigned short port;
};

Et voilà comment utiliser ces classes pour envoyer et recevoir des paquets :

// créer le socket  const int port = 30000;
Socket socket;
if ( !socket.Open( port ) )
{
printf( "Erreur a la creation du socket!\n" );
return false;
}
// envoyer un paquet const char data[] = "hello world!";
socket.Send( Address(127,0,0,1,port), data, sizeof( data ) );
// recevoir des paquets
while ( true )
{
Address sender;
unsigned char buffer[256];
int bytes_read =  socket.Receive( sender,  buffer,  sizeof( buffer ) );
if ( !bytes_read ) break;
// traiter le paquet
}

Comme vous pouvez le voir, c'est beaucoup plus simple que d'utiliser les sockets BSD directement. En prime le code est identique sur toutes les plateformes, parce que tout ce qui est dépendant de la plateforme est géré dans vos classes Socket et Adress.

XI - Conclusion

Nous avons maintenant une manière indépendante de la plateforme d'envoyer et recevoir des paquets UDP.

UDP est un protocole non connecté, et j'ai voulu créer un programme d'exemple qui montre bien ça. Donc j'ai créé un simple programme qui lit des adresses IP d'un fichier texte et envoie un paquet à ces adresses toutes les secondes. Chaque fois que le programme reçoit un paquet, il affiche de quelle machine il provient, et la taille du paquet reçu.

Vous pouvez aisément le modifier pour avoir un nombre de nœuds qui s'envoient des paquets sur votre ordinateur, il suffit d'utiliser un port différent pour chaque instance du programme comme ceci :

> Node 30000
> Node 30001
> Node 30002
etc.

Ainsi chaque nœud essayera d'envoyer des paquets à chaque autre nœud, comme une mini-installation peer-to-peer.

Ce programme a été développé sous Mac OS X, mais vous devriez pouvoir le compiler sur n'importe quel système Unix assez facilement.

XII - Remerciements

Cet article est une traduction autorisée de l'article de Glenn Fiedler.

Merci aussi à Claude Leloup pour sa relecture orthographique.