aslain.dev
0%
01 Hizmetler 02 Hakkımda 03 Projeler 04 Stack 05 Blog 06 İletişim
← Tüm makaleler Serveur de Jeu

Conception de paquets de jeu : en-tête, sérialisation, ordre des octets

Une bonne conception de paquets de jeu est la première pierre qui détermine si un serveur de jeu va passer à l'échelle et si le client et le serveur parlent vraiment la même langue. Quand vous envoyez des données via TCP, vous envoyez en réalité un flux d'octets ; la notion de « message » n'existe pas par défaut. Vous devez donc transformer des octets bruts en un protocole structuré que les deux côtés peuvent lire et écrire de la même manière. Dans cet article, je détaille la construction d'un protocole binaire solide à partir de zéro, avec le trio en-tête, sérialisation et ordre des octets.

TCP est un flux, pas un message

La première erreur, et la plus fréquente, est de supposer qu'« un send() égale un recv() ». TCP ne garantit rien de tel. Les données que vous poussez avec un seul send() peuvent arriver de l'autre côté en deux morceaux distincts ; ou deux paquets envoyés coup sur coup peuvent atterrir fusionnés dans un seul appel recv() (on parle de « TCP coalescing »). Autrement dit, redécouper les données selon les frontières de message est entièrement votre travail.

Il existe deux façons classiques de résoudre cela :

  • Préfixé par la longueur : vous écrivez au début de chaque paquet le nombre d'octets du corps. L'approche la plus courante et la plus fiable.
  • Basé sur un délimiteur : vous séparez les messages par un octet spécial (par exemple \n). C'est risqué pour des données binaires car le délimiteur peut aussi apparaître dans la charge utile ; on le préfère pour les protocoles textuels.

Les jeux utilisent presque toujours un protocole binaire préfixé par la longueur, car il est rapide, compact et sans ambiguïté.

Conception de l'en-tête : l'identité de chaque paquet

L'en-tête est un bloc de taille fixe placé avant le corps qui vous dit comment lire le paquet. Un en-tête minimal mais utile contient généralement :

  • length — la longueur en octets du corps (ou du paquet entier). Pour découper le flux en messages.
  • opcode / type — ce qu'est ce paquet (par ex. login, move, chat). Pour l'acheminer vers le bon gestionnaire.
  • Facultatif : un numéro de séquence, des flags (compressé/chiffré ?), la version du protocole.

Un en-tête typique s'exprime ainsi en C++ :

#include <cstdint>

#pragma pack(push, 1)
struct PacketHeader {
    uint16_t length;   // longueur du corps (octets)
    uint16_t opcode;   // type de paquet
};
#pragma pack(pop)

#pragma pack(push, 1) est ici crucial : le compilateur ajoute du padding (espaces d'alignement) entre les champs de la structure pour la performance. Ce padding varie selon le compilateur et la plateforme, donc si vous envoyez la structure directement sur le réseau, les deux côtés peuvent voir une disposition d'octets différente. Désactiver le padding — ou, plus robuste encore, sérialiser les champs un par un — élimine ce problème.

Ordre des octets : little-endian ou big-endian ?

Quand vous écrivez en mémoire un nombre multi-octets (par exemple un uint32_t de 4 octets), l'ordre des octets dépend de l'architecture. x86/x64 et ARM (en général) sont little-endian ; la convention dans les protocoles réseau est le big-endian (« network byte order »). Si les deux côtés supposent un ordre différent, le nombre 1 est lu à l'autre bout comme 16777216.

La solution : choisir un seul ordre des octets sur le fil et l'appliquer de façon cohérente aux deux bouts. Sur les systèmes POSIX, les fonctions de conversion sont fournies d'office :

#include <arpa/inet.h>

uint16_t net = htons(host_value);   // host -> network (big-endian)
uint16_t host = ntohs(net);         // network -> host
uint32_t net32 = htonl(host_value); // versions 32 bits

Ces fonctions ne couvrent que 16 et 32 bits ; pour des valeurs 64 bits, il faut découper en deux moitiés de 32 bits ou écrire son propre utilitaire. Tant que le client et le serveur supposent toujours le même endianness (par exemple deux PC little-endian), aucune conversion n'est techniquement nécessaire ; mais fixer explicitement le protocole sur un endianness vous évitera des maux de tête plus tard, lorsque vous ajouterez une plateforme différente.

Sérialisation : n'envoyez pas la structure directement

Un raccourci tentant mais dangereux : send(sock, &packet, sizeof(packet), 0). Cela envoie la disposition mémoire de la structure telle quelle et casse entre plateformes à cause du padding, de l'endianness, voire de l'alignement des pointeurs/structures. Écrivez plutôt les champs explicitement dans un tampon :

void writeUint16(std::vector<uint8_t>& buf, uint16_t v) {
    uint16_t net = htons(v);
    buf.push_back(net & 0xFF);
    buf.push_back((net >> 8) & 0xFF);
}

uint16_t readUint16(const uint8_t* data) {
    uint16_t net = data[0] | (data[1] << 8);
    return ntohs(net);
}

En sérialisant des chaînes, n'oubliez pas le préfixe de longueur : d'abord une longueur uint16_t, puis ce nombre d'octets. Ainsi le côté lecteur sait où cela se termine. Au lieu d'écrire votre propre format, vous pouvez aussi utiliser une bibliothèque mature — Protocol Buffers, FlatBuffers ou Cap'n Proto offrent une sérialisation basée sur un schéma, versionnable, et réduisent la charge de maintenance, surtout à mesure que le protocole grandit.

La boucle de lecture : réassembler des données fragmentées

Gardez un tampon de réception par connexion. Ajoutez-y les octets venant de recv(), puis vérifiez dans une boucle « y a-t-il assez de données ? » :

// logique grossière (gestion d'erreurs réduite)
buffer.append(recv_data);
while (buffer.size() >= sizeof(PacketHeader)) {
    uint16_t len = readUint16(buffer.data()); // longueur du corps
    size_t total = sizeof(PacketHeader) + len;
    if (buffer.size() < total) break;         // paquet pas encore complet
    handlePacket(buffer.data(), total);
    buffer.erase_front(total);                // jeter la partie traitée
}

Cette boucle gère correctement à la fois le cas fragmenté (un paquet réparti sur plusieurs recv) et le cas fusionné (plusieurs paquets dans un seul recv). Pour une qualité de production, deux choses de plus sont essentielles : imposer une borne supérieure au champ length (par ex. 64 Ko) pour qu'un client malveillant ne puisse pas envoyer une longueur énorme et épuiser votre mémoire ; et valider chaque champ entrant avant de le passer au gestionnaire.

Questions fréquentes

Dois-je utiliser UDP au lieu de TCP ?

Cela dépend. TCP offre une livraison ordonnée et fiable ; il est idéal pour des données qui ne tolèrent pas la perte, comme le login, l'inventaire et le chat. Pour les jeux d'action rapides (FPS, combat), UDP est préféré pour la faible latence, mais vous devez ajouter vous-même la fiabilité (ordre, retransmission). Beaucoup de jeux utilisent les deux ensemble.

Dois-je chiffrer l'en-tête ?

En général, chiffrer le corps suffit, mais des champs de base comme length doivent rester lisibles pour pouvoir découper le flux. Une approche courante consiste à envelopper tout le trafic après la poignée de main (handshake) dans une couche de type TLS ; dans ce cas, votre protocole de paquets tourne à l'intérieur du tunnel chiffré et la logique d'ordre des octets/sérialisation reste inchangée.

Comment faire évoluer le protocole plus tard ?

Mettez un champ version dans l'en-tête et ajoutez de nouveaux opcodes sans casser les anciens. La sérialisation basée sur un schéma (comme Protobuf) rend l'ajout et la suppression de champs rétrocompatibles ; dans les protocoles écrits à la main, vous devez maintenir cette discipline vous-même.

Besoin d'un protocole de jeu solide ? Des serveurs de type Metin2 aux couches réseau C++ sur mesure, je peux vous aider sur la conception de paquets et le networking — contactez-moi.

Devamı için