Construire un protocole de jeu en réseau

Techniques de sérialisation

Glenn Fiedler

Traducteur

Bousk

Commentez

- Titre : Construire un protocole de jeu en réseau

- Auteur : Glenn Fiedler

- Traducteur : Bousk

- Parution : 1 juin 2017

- Mise à jour : 19 juillet 2017

- Public visé :

- 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 ® 2017 Cyrille (Bousk) Bousquet. 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. Fonction de sérialisation de paquets unifiée

- III. Vérification des bornes et abandon de lecture

- IV. Sérialiser flottants et vecteurs

- V. Sérialiser chaînes de caractères et tableaux

- VI. Sérialiser une partie de tableau

- VII. ID de protocole, CRC32 et vérifications de sérialisation

- VIII. Remerciements

I - Introduction

Bonjour, je suis Glenn Fiedler et bienvenue sur le deuxième article de Construire un protocole de jeu en réseau.

Dans l'article précédent nous avons discuté des différentes manières de lire et écrire des paquets dans les jeux multijoueurs. Nous avons rapidement mis de côté l'envoi de l'état du jeu via des formats textes comme XML ou JSON parce qu'ils sont vraiment inefficaces et nous avons décidé d'écrire notre propre protocole binaire. Nous avons implémenté un bitpacker pour ne pas avoir à perdre jusque 7 bits pour un booléen, résoudre les problèmes d'endianness, écrire un mot entier au lieu d'octets et faire en sorte qu'il soit aussi simple et rapide que possible sans astuce spécifique à une plateforme.

Après ce premier article, il restait à résoudre les problèmes suivants :

1. Nous avons besoin de vérifier si les valeurs entières sont hors de l'intervalle de valeurs attendu et interrompre la lecture du paquet parce que des personnes malveillantes peuvent envoyer un paquet qui essayera de corrompre la mémoire. L'interruption de la lecture du paquet doit être automatique et ne pas utiliser les exceptions parce qu'elles sont vraiment lentes ;

2. Séparer les fonctions de lecture et d'écriture est un cauchemar à maintenir si ces fonctions sont écrites manuellement. Nous aimerions écrire le code de sérialisation pour un paquet donné une seule fois, mais ne pas avoir de surcoût à l'exécution (en termes de chemins conditionnels, appels virtuels, etc.).

Comment faire ? Lisez la suite de cet article et je vous montrerai exactement comment j'y parviens en C++. J'ai mis du temps à développer et peaufiner cette technique alors j'espère que vous la trouverez utile et qu'il s'agira au moins d'un bon substitut par rapport à la façon dont vous le faites ou l'avez vu faire dans d'autres moteurs de jeux.

II - Fonction de sérialisation de paquets unifiée

Commençons par l'objectif. Voici ce que nous voulons avoir à la fin :

Sérialisation unifiée

1. struct PacketA

2. {

3. int x,y,z;

5. template <typename Stream> bool Serialize( Stream & stream )

6. {

7. serialize_bits( stream, x, 32 );

8. serialize_bits( stream, y, 32 );

9. serialize_bits( stream, z, 32 );

10. return true;

11. }

12. };

14. struct PacketB

15. {

16. int numElements;

17. int elements[MaxElements];

19. template <typename Stream> bool Serialize( Stream & stream )

20. {

21. serialize_int( stream, numElements, 0, MaxElements );

22. for ( int i = 0; i < numElements; ++i )

23. serialize_bits( buffer, elements[i], 32 );

24. return true;

25. }

26. };

28. struct PacketC

29. {

30. bool x;

31. short y;

32. int z;

34. template <typename Stream> bool Serialize( Stream & stream )

35. {

36. serialize_int( stream, x, 8 );

37. serialize_int( stream, y, 16 );

38. serialize_int( stream, z, 32 );

39. return true;

40. }

41. };

Remarquez qu'il y a une seule fonction de sérialisation par structure de paquet au lieu de fonctions read et write séparées. C'est cool ! La quantité de code de sérialisation est diminuée de moitié et maintenant vous devez déployer de sérieux efforts pour désynchroniser la lecture et l'écriture.

L'astuce pour que ce soit efficace est que la classe stream soit template dans la fonction. Il y a deux types de stream dans mon système : ReadStream et WriteStream. Chaque classe a les mêmes méthodes, mais elles n'ont aucun lien entre elles. Une classe lit les valeurs d'un flux binaire vers les variables, l'autre écrit les valeurs des variables dans un flux binaire. ReadStream et WriteStream sont de simples wrappers aux classes BitReader et BitWriter de l'article précédent.

Il y a bien sûr d'autres voies à cette approche. Si vous n'appréciez pas les templates vous pourriez avoir une interface stream et l'implémenter avec la lecture et l'écriture pour chaque classe de sérialisation. Mais vous avez maintenant une fonction virtuelle appelée pour chaque sérialisation. Un surcoût excessif selon moi.

Une autre option est d'avoir une super-class stream qui peut être configurée pour agir en lecture ou écriture à l'exécution. Ceci peut être plus rapide que la méthode virtuelle, mais vous avez toujours une branche conditionnelle pour chaque sérialisation pour choisir entre lecture ou écriture, ce ne sera donc pas aussi rapide qu'une fonction read et write codée à la main.

Je préfère la méthode du template parce que ça laisse le compilateur faire le travail de génération et optimisation des fonctions read et write pour vous. Vous pouvez aussi coder des fonctions de sérialisation de cette manière et laisser le compilateur optimiser ce qu'il souhaite en spécialisant read et write :

1. struct RigidBody

2. {

3. vec3f position;

4. quat3f orientation;

5. vec3f linear_velocity;

6. vec3f angular_velocity;

8. template <typename Stream> bool Serialize( Stream & stream )

9. {

10. serialize_vector( stream, position );

11. serialize_quaternion( stream, orientation );

13. bool at_rest = Stream::IsWriting ? velocity.length() == 0 : 1;

15. serialize_bool( stream, at_rest );

17. if ( !at_rest )

18. {

19. serialize_vector( stream, linear_velocity );

20. serialize_vector( stream, angular_velocity );

21. }

22. else if ( Stream::IsReading )

23. {

24. linear_velocity = vec3f(0,0,0);

25. angular_velocity = vec3f(0,0,0);

26. }

28. return true;

29. }

30. };

Alors que ça peut sembler non efficace, ça l'est ! La spécialisation de template de cette fonction optimise toutes les branches conditionnelles selon le type de stream. Pas mal hein ?

III - Vérification des bornes et abandon de lecture

Maintenant que vous avez tordu le cou du compilateur pour générer des fonctions read et write optimisées, nous devons automatiser la vérification des erreurs à la lecture pour se protéger contre les paquets malveillants.

La première étape est d'inclure les bornes de l'entier à la fonction de sérialisation au lieu d'utiliser uniquement le nombre de bits. Pensez-y. La fonction de sérialisation peut extrapoler le nombre de bits requis depuis les valeurs minimales et maximales :

serialize_int( stream, numElements, 0, MaxElements );

Ceci permet à l'interface de supporter aisément la sérialisation d'entiers signés et la fonction de sérialisation peut vérifier la valeur lue depuis le réseau et s'assurer qu'elle est dans la plage de valeurs attendues. Si la valeur est hors borne, avorter la lecture immédiatement et ignorer le paquet.

Puisque nous ne pouvons pas utiliser les exceptions pour gérer cet abandon (trop lentes), voici comment nous allons faire.

Dans ma configuration serialize_int n'est actuellement pas une fonction, c'est une macro sournoise comme ceci :

serialize_int

1. #define serialize_int( stream, value, min, max )                    \

2. do                                                              \

3. {                                                               \

4. assert( min < max );                                        \

5. int32_t int32_value;                                        \

6. if ( Stream::IsWriting )                                    \

7. {                                                           \

8. assert( value >= min );                                 \

9. assert( value <= max );                                 \

10. int32_value = (int32_t) value;                          \

11. }                                                           \

12. if ( !stream.SerializeInteger( int32_value, min, max ) )    \

13. return false;                                           \

14. if ( Stream::IsReading )                                    \

15. {                                                           \

16. value = int32_value;                                    \

17. if ( value < min || value > max )                       \

18. return false;                                       \

19. }                                                           \

20. } while (0)

Je suis une très mauvaise personne ici parce que j'utilise la macro pour insérer le code de vérification des résultats de SerializeInteger et retourner false en cas d'erreur. Ceci vous donne un comportement similaire à une exception dans le sens où la pile d'exécution revient au début de la fonction de sérialisation en cas d'erreur, mais vous n'avez aucun coût contrairement aux exceptions pour y parvenir. Le branchement pour dérouler est très peu commun (les erreurs de sérialisation sont rares) donc les prédictions de branchements ne devraient poser aucun souci.

Un autre cas qui nécessite l'interruption est si la lecture atteint la fin du tampon. C'est également un branchement plutôt rare, mais ça doit être vérifié pour chaque opération de sérialisation parce que lire après la fin du tampon est un comportement indéterminé. Si nous ne réalisons pas cette vérification, nous pourrions rencontrer des boucles infinies. Tandis qu'il est courant de retourner la valeur 0 quand on lit après la fin d'un flux (d'après l'article précédent), il n'y a aucune garantie que ceci entraîne la fin de la fonction de sérialisation correctement si elle utilise des boucles. Cette vérification du débordement est nécessaire afin d'avoir un comportement bien défini.

Une dernière chose. En sérialisant les données, je n'interromps jamais l'écriture en vérifiant les bornes ou après la fin du flux. Vous pouvez être beaucoup plus souple sur l'écriture puisque si un problème survient c'est quasi garanti d'être de votre faute. Il suffit d'assurer que tout est correct, via assertions, (bornes correctes, fin du flux non atteint) pour chaque sérialisation et vous êtes bon.

IV - Sérialiser flottants et vecteurs

Le flux d'octets ne sérialise que des valeurs entières. Comment pouvons-nous sérialiser une valeur flottante ?

Ça a l'air technique, mais ne l'est pas. Un nombre flottant en mémoire est juste une valeur sur 32 bits comme toutes les autres. Votre ordinateur ne sait pas si un mot de 32 bits en mémoire est un entier, un flottant ou une partie de chaîne de caractères. C'est juste une valeur sur 32 bits. Par chance, le langage C++ (contrairement à quelques autres) nous laisse travailler avec cette propriété fondamentale.

Vous pouvez accéder la valeur entière derrière un nombre flottant avec une union :

1. union FloatInt

2. {

3. float float_value;

4. uint32_t int_value;

5. };

7. FloatInt tmp;

8. tmp.float_value = 10.0f;

9. printf( "float value as an integer: %x\n", tmp.int_value );

Vous pouvez aussi y parvenir via un uint32_t*, mais d'expérience ça ne passe pas avec GCC -O2, donc je préfère la technique de l'union à la place. Des amis ont aussi fait remarquer (à juste titre) que le seul moyen absolument standard d'avoir le flottant comme entier est de convertir un pointeur vers le flottant en uint8_t* et reconstruire la valeur entière depuis les valeurs des quatre octets accédés individuellement via un pointeur d'octet. Ça semble une manière un peu absurde de le faire selon moi. Mesdames et messieurs… C++ !

En attendant, ces cinq dernières années, je n'ai eu aucun problème avec la technique de l'union. Voici comment je sérialise une valeur flottante non compressée :

serialize_float_internal

1. template <typename Stream>

2. bool serialize_float_internal( Stream & stream,

3. float & value )

4. {

5. union FloatInt

6. {

7. float float_value;

8. uint32_t int_value;

9. };

11. FloatInt tmp;

12. if ( Stream::IsWriting )

13. tmp.float_value = value;

15. bool result = stream.SerializeBits( tmp.int_value, 32 );

17. if ( Stream::IsReading )

18. value = tmp.float_value;

20. return result;

21. }

Encapsulez ceci dans une macro serialize_float pour facilement gérer les erreurs à la lecture :

serialize_float

1. #define serialize_float( stream, value )                             \

2. do                                                                 \

3. {                                                                  \

4. if ( !protocol2::serialize_float_internal( stream, value ) )     \

5. return false;                                                \

6. } while (0)

Parfois vous n'avez pas besoin de toute la précision offerte par un type flottant. Comment pouvez-vous compresser une valeur flottante ? La première étape est de borner cette valeur puis de la quantifier afin d'en faire une représentation entière.

Par exemple, si vous savez qu'un flottant est dans les bornes [-10, +10] et qu'une précision suffisante pour cette valeur est 0,01, alors vous pouvez juste multiplier ce nombre par 100 pour l'avoir entre [-1000, +1000] et sérialiser en entier. Du côté de la réception, divisez juste par 100.0 pour retrouver la valeur flottante.

Voici une version généralisée de ce concept :

serialize_compressed_float_internal

1. template <typename Stream>

2. bool serialize_compressed_float_internal( Stream & stream,

3. float & value,

4. float min,

5. float max,

6. float res )

7. {

8. const float delta = max - min;

9. const float values = delta / res;

10. const uint32_t maxIntegerValue = (uint32_t) ceil( values );

11. const int bits = bits_required( 0, maxIntegerValue );

13. uint32_t integerValue = 0;

15. if ( Stream::IsWriting )

16. {

17. float normalizedValue =

18. clamp( ( value - min ) / delta, 0.0f, 1.0f );

19. integerValue = (uint32_t) floor( normalizedValue *

20. maxIntegerValue + 0.5f );

21. }

23. if ( !stream.SerializeBits( integerValue, bits ) )

24. return false;

26. if ( Stream::IsReading )

27. {

28. const float normalizedValue =

29. integerValue / float( maxIntegerValue );

30. value = normalizedValue * delta + min;

31. }

33. return true;

34. }

Une fois que vous pouvez sérialiser des valeurs flottantes, il est trivial d'ajouter à la sérialisation de vecteurs et quaternions. J'utilise une version modifiée de la superbe bibliothèque vectorial pour les vecteurs mathématiques dans mes projets et j'implémente la sérialisation de ces objets comme ceci :

serialize_vector_internal

1. template <typename Stream>

2. bool serialize_vector_internal( Stream & stream,

3. vec3f & vector )

4. {

5. float values[3];

6. if ( Stream::IsWriting )

7. vector.store( values );

8. serialize_float( stream, values[0] );

9. serialize_float( stream, values[1] );

10. serialize_float( stream, values[2] );

11. if ( Stream::IsReading )

12. vector.load( values );

13. return true;

14. }

16. template <typename Stream>

17. bool serialize_quaternion_internal( Stream & stream,

18. quat4f & quaternion )

19. {

20. float values[4];

21. if ( Stream::IsWriting )

22. quaternion.store( values );

23. serialize_float( stream, values[0] );

24. serialize_float( stream, values[1] );

25. serialize_float( stream, values[2] );

26. serialize_float( stream, values[3] );

27. if ( Stream::IsReading )

28. quaternion.load( values );

29. return true;

30. }

32. #define serialize_vector( stream, value )                       \

33. do                                                             \

34. {                                                              \

35. if ( !serialize_vector_internal( stream, value ) )         \

36. return false;                                          \

37. }                                                              \

38. while(0)

40. #define serialize_quaternion( stream, value )                   \

41. do                                                             \

42. {                                                              \

43. if ( !serialize_quaternion_internal( stream, value ) )     \

44. return false;                                          \

45. }                                                              \

46. while(0)

Si vous savez que les composantes de votre vecteur sont bornées, vous pouvez les compresser comme ceci :

serialize_compressed_vector_internal

1. template <typename Stream>

2. bool serialize_compressed_vector_internal( Stream & stream,

3. vec3f & vector,

4. float min,

5. float max,

6. float res )

7. {

8. float values[3];

9. if ( Stream::IsWriting )

10. vector.store( values );

11. serialize_compressed_float( stream, values[0], min, max, res );

12. serialize_compressed_float( stream, values[1], min, max, res );

13. serialize_compressed_float( stream, values[2], min, max, res );

14. if ( Stream::IsReading )

15. vector.load( values );

16. return true;

17. }

Si vous voulez compresser une orientation à travers le réseau, ne vous contentez pas de la compresser comme vecteur 8.8.8.8 borné par [-1, +1]. Vous pouvez avoir un bien meilleur résultat si vous utilisez le plus petit représentant du quaternion. Voyez le code exemple de cet article pour une implémentation.

V - Sérialiser chaînes de caractères et tableaux

Et si vous voulez sérialiser une chaîne de caractères ?

Est-ce une bonne idée d'envoyer la chaîne avec le caractère nul de fin de chaîne ? Je ne pense pas. Vous cherchez juste les ennuis ! Au lieu de ça, traitez la chaîne comme un tableau d'octets préfixé de sa longueur. Donc, afin d'envoyer une chaîne de caractères via le réseau, nous devons travailler sur comment envoyer un tableau d'octets efficacement.

Première observation : pourquoi se fatiguer à tasser un tableau d'octets dans notre flux juste pour qu'ils soient aléatoirement décalés de [0, 7] bits ? Pourquoi ne pas se contenter d'aligner à l'octet avant d'écrire le tableau, pour que ses données soient parfaitement alignées dans le paquet, afin que chaque octet du tableau corresponde à un octet du paquet. Vous perdez seulement [0,7] bit pour chaque tableau sérialisé, selon l'alignement, mais ce n'est pas un très grave selon moi.

Comment aligner le flux à l'octet ? À partir de l'index du bit actuel dans le flux, calculez le nombre de bits nécessaires pour atteindre l'octet suivant, combien de bits manquent pour que cet index soit divisible par 8, puis insérez ce nombre de bits en remplissage. En bonne pratique, remplissez-les de 0 afin qu'à la lecture vous puissiez vérifier que oui, vous lisez un octet aligné et oui, il est en effet rempli de zéro jusqu'à l'index du prochain octet. Si un bit non nul est lu dans le remplissage, interrompez la lecture et rejetez le paquet.

Voici mon code pour aligner un flux à l'octet suivant :

Alignement d'un flux à l'octet

1. void BitWriter::WriteAlign()

2. {

3. const int remainderBits = m_bitsWritten % 8;

4. if ( remainderBits != 0 )

5. {

6. uint32_t zero = 0;

7. WriteBits( zero, 8 - remainderBits );

8. assert( ( m_bitsWritten % 8 ) == 0 );

9. }

10. }

12. bool BitReader::ReadAlign()

13. {

14. const int remainderBits = m_bitsRead % 8;

15. if ( remainderBits != 0 )

16. {

17. uint32_t value = ReadBits( 8 - remainderBits );

18. assert( m_bitsRead % 8 == 0 );

19. if ( value != 0 )

20. return false;

21. }

22. return true;

23. }

25. #define serialize_align( stream )           \

26. do                                        \

27. {                                         \

28. if ( !stream.SerializeAlign() )       \

29. return false;                     \

30. } while (0)

Nous pouvons maintenant utiliser cette opération d'alignement pour écrire efficacement un tableau d'octets dans notre flux binaire : puisque nous sommes alignés à l'octet, nous pouvons faire la majeure partie du travail via memcpy. La seule astuce est, puisque la lecture et écriture se fait au niveau du mot, qu'il est nécessaire d'avoir du code spécifique pour gérer le début et la fin du tableau, pour s'assurer que tous les bits dans scratch sont écrits en mémoire au début, et que le scratch soit correctement configuré pour les écritures suivantes après la sérialisation de notre tableau.

Alignement d'un flux à l'octet

1. void BitWriter::WriteBytes( const uint8_t* data, int bytes )

2. {

3. assert( GetAlignBits() == 0 );

4. assert( m_bitsWritten + bytes * 8 <= m_numBits );

5. assert( ( m_bitsWritten % 32 ) == 0 ||

6. ( m_bitsWritten % 32 ) == 8 ||

7. ( m_bitsWritten % 32 ) == 16 ||

8. ( m_bitsWritten % 32 ) == 24 );

10. int headBytes = ( 4 - ( m_bitsWritten % 32 ) / 8 ) % 4;

11. if ( headBytes > bytes )

12. headBytes = bytes;

13. for ( int i = 0; i < headBytes; ++i )

14. WriteBits( data[i], 8 );

15. if ( headBytes == bytes )

16. return;

18. assert( GetAlignBits() == 0 );

20. int numWords = ( bytes - headBytes ) / 4;

21. if ( numWords > 0 )

22. {

23. assert( ( m_bitsWritten % 32 ) == 0 );

24. memcpy( &m_data[m_wordIndex], data+headBytes, numWords*4 );

25. m_bitsWritten += numWords * 32;

26. m_wordIndex += numWords;

27. m_scratch = 0;

28. }

30. assert( GetAlignBits() == 0 );

32. int tailStart = headBytes + numWords * 4;

33. int tailBytes = bytes - tailStart;

34. assert( tailBytes >= 0 && tailBytes < 4 );

35. for ( int i = 0; i < tailBytes; ++i )

36. WriteBits( data[tailStart+i], 8 );

38. assert( GetAlignBits() == 0 );

40. assert( headBytes + numWords * 4 + tailBytes == bytes );

41. }

43. void ReadBytes( uint8_t* data, int bytes )

44. {

45. assert( GetAlignBits() == 0 );

46. assert( m_bitsRead + bytes * 8 <= m_numBits );

47. assert( ( m_bitsRead % 32 ) == 0 ||

48. ( m_bitsRead % 32 ) == 8 ||

49. ( m_bitsRead % 32 ) == 16 ||

50. ( m_bitsRead % 32 ) == 24 );

52. int headBytes = ( 4 - ( m_bitsRead % 32 ) / 8 ) % 4;

53. if ( headBytes > bytes )

54. headBytes = bytes;

55. for ( int i = 0; i < headBytes; ++i )

56. data[i] = ReadBits( 8 );

57. if ( headBytes == bytes )

58. return;

60. assert( GetAlignBits() == 0 );

62. int numWords = ( bytes - headBytes ) / 4;

63. if ( numWords > 0 )

64. {

65. assert( ( m_bitsRead % 32 ) == 0 );

66. memcpy( data + headBytes, &m_data[m_wordIndex], numWords * 4 );

67. m_bitsRead += numWords * 32;

68. m_wordIndex += numWords;

69. m_scratchBits = 0;

70. }

72. assert( GetAlignBits() == 0 );

74. int tailStart = headBytes + numWords * 4;

75. int tailBytes = bytes - tailStart;

76. assert( tailBytes >= 0 && tailBytes < 4 );

77. for ( int i = 0; i < tailBytes; ++i )

78. data[tailStart+i] = ReadBits( 8 );

80. assert( GetAlignBits() == 0 );

82. assert( headBytes + numWords * 4 + tailBytes == bytes );

83. }

85. template <typename Stream>

86. bool serialize_bytes_internal( Stream & stream,

87. uint8_t* data,

88. int bytes )

89. {

90. return stream.SerializeBytes( data, bytes );

91. }

93. #define serialize_bytes( stream, data, bytes )                    \

94. do                                                              \

95. {                                                               \

96. if ( !serialize_bytes_internal( stream, data, bytes ) )     \

97. return false;                                           \

98. } while (0)

Nous pouvons maintenant sérialiser une chaîne de caractères en sérialisant sa longueur suivie de ses données :

serialize_string_internal

1. template <typename Stream>

2. bool serialize_string_internal( Stream & stream,

3. char* string,

4. int buffer_size )

5. {

6. uint32_t length;

7. if ( Stream::IsWriting )

8. {

9. length = strlen( string );

10. assert( length < buffer_size - 1 );

11. }

12. serialize_int( stream, length, 0, buffer_size - 1 );

13. serialize_bytes( stream, (uint8_t*)string, length );

14. if ( Stream::IsReading )

15. string[length] = '\0';

16. }

18. #define serialize_string( stream, string, buffer_size )              \

19. do                                                                   \

20. {                                                                    \

21. if ( !serialize_string_internal( stream,                         \

22. string, buffer_size ) )         \

23. return false;                                                \

24. } while (0)

Comme vous pouvez le constater, vous pouvez avoir des sérialisations assez complexes à partir de simples primitives.

VI - Sérialiser une partie de tableau

Lors de l'implémentation d'un protocole réseau de jeu, tôt ou tard vous aurez besoin de sérialiser un tableau d'objets. Peut-être que le serveur doit envoyer tous les objets à un client, ou un tableau d'évènements ou de messages. C'est assez simple si vous envoyez tous les objets dans le tableau vers le client, mais qu'en est-il si vous voulez envoyer uniquement un sous-ensemble du tableau ?

La première approche et la plus simple est d'itérer sur l'ensemble des objets du tableau et de sérialiser un booléen par objet pour indiquer si cet objet doit être envoyé ou non. Si ce booléen vaut 1 alors les données de l'objet suivent, sinon il est omis et le booléen pour l'objet suivant suit immédiatement dans le flux.

1. template <typename Stream>

2. bool serialize_scene_a( Stream & stream, Scene & scene )

3. {

4. for ( int i = 0; i < MaxObjects; ++i )

5. {

6. serialize_bool( stream, scene.objects[i].send );

8. if ( !scene.objects[i].send )

9. {

10. if ( Stream::IsReading )

11. memset( &scene.objects[i], 0, sizeof( Object ) );

12. continue;

13. }

15. serialize_object( stream, scene.objects[i] );

16. }

18. return true;

19. }

Mais que se passe-t-il si le tableau est très large, disons 4000 objets dans la scène ? 4000/8 = 500. Houla. On a un surcoût de 500 octets, même si vous envoyez uniquement un ou deux objets ! C'est… pas bon. Pouvons-nous changer ça pour avoir un surcoût proportionnel au nombre d'objets à envoyer et non au total d'objets du tableau ?

Maintenant oui, nous avons fait quelque chose d'intéressant. Nous itérons une collection d'objets pour l'écriture (tous les objets du tableau) et une collection différente pour la lecture (sous-ensemble d'objets envoyés). À cet instant le concept de sérialisation unifiée pour la lecture et écriture ne fonctionne pas. Pour de tels cas, il est mieux de séparer la lecture et l'écriture dans des fonctions séparées comme ceci :

1. bool write_scene_b( protocol2::WriteStream & stream, Scene & scene )

2. {

3. int num_objects_sent = 0;

5. for ( int i = 0; i < MaxObjects; ++i )

6. {

7. if ( scene.objects[i].send )

8. num_objects_sent++;

9. }

11. write_int( stream, num_objects_sent, 0, MaxObjects );

13. for ( int i = 0; i < MaxObjects; ++i )

14. {

15. if ( !scene.objects[i].send )

16. continue;

17. write_int( stream, i, 0, MaxObjects - 1 );

18. write_object( stream, scene.objects[i] );

19. }

21. return true;

22. }

24. bool read_scene_b( protocol2::ReadStream & stream, Scene & scene )

25. {

26. memset( &scene, 0, sizeof( scene ) );

28. int num_objects_sent;

29. read_int( stream, num_objects_sent, 0, MaxObjects );

31. for ( int i = 0; i < num_objects_sent; ++i )

32. {

33. int index;

34. read_int( stream, index, 0, MaxObjects - 1 );

35. read_object( stream, scene.objects[index] );

36. }

38. return true;

39. }

Comme autre solution, vous pourriez générer une structure de données séparées avec le sous-ensemble des objets mis à jour, et implémenter une sérialisation pour ce tableau d'objets mis à jour. Mais devoir générer une struct C++ pour chaque structure de données que vous voulez sérialiser est très laborieux. Finalement vous voulez itérer sur plusieurs structures de données en parallèle et effectivement écrire une structure de données dynamique via le flux binaire. C'est quelque chose de très banal lors de la mise en place de méthodes de sérialisation plus avancées comme le delta encoding. Dès que vous le faites ainsi, la sérialisation unifiée n'a plus de raison d'être.

Mon conseil est que quand vous voulez le faire, ne vous tracassez pas, séparez juste la lecture et l'écriture. Unifier lecture et écriture ne vaut pas le coup face aux problèmes qui se poseront quand vous générez dynamiquement une structure de données à l'écriture. Ma règle d'or est que la sérialisation compliquée justifie probablement d'avoir à séparer les fonctions de lecture et écriture, mais si possible, essayez de garder les dénominateurs communs unifiés (c'est-à-dire les objets, évènements, quoi que ce soit que vous sérialisiez).

Un dernier point. Le code plus haut itère sur la collection d'objets deux fois lors de l'écriture. Une première fois pour déterminer le nombre d'objets qui ont été modifiés, et une seconde fois pour réellement sérialiser le sous-ensemble des objets modifiés. Pouvons-nous y parvenir en une seule passe ? Absolument ! Vous pouvez utiliser une autre astuce, une valeur sentinelle pour indiquer la fin du tableau, plutôt que de sérialiser le nombre d'objets présents dans le tableau en amont. De cette manière vous pouvez itérer sur le tableau une unique fois lors de l'envoi, et quand il n'y a plus d'objets à envoyer, sérialiser la valeur sentinelle pour indiquer la fin du tableau :

1. bool write_scene_c( protocol2::WriteStream & stream, Scene & scene )

2. {

3. for ( int i = 0; i < MaxObjects; ++i )

4. {

5. if ( !scene.objects[i].send )

6. continue;

7. write_int( stream, i, 0, MaxObjects );

8. write_object( stream, scene.objects[i] );

9. }

11. write_int( stream, MaxObjects, 0, MaxObjects );

13. return true;

14. }

16. bool read_scene_c( protocol2::ReadStream & stream, Scene & scene )

17. {

18. memset( &scene, 0, sizeof( scene ) );

20. while ( true )

21. {

22. int index; read_int( stream, index, 0, MaxObjects );

23. if ( index == MaxObjects )

24. break;

25. read_object( stream, scene.objects[index] );

26. }

28. return true;

29. }

C'est plutôt simple et ça fonctionne très bien si l'ensemble des objets envoyés est un petit pourcentage du total des objets. Mais que se passe-t-il si un grand nombre d'objets sont envoyés, disons la moitié des 4000 objets de la scène. On aura 2000 indices d'objet avec chaque index coûtant 12 bits… ce qui fait 24 000 bits ou 3000 octets (presque 3 ko !) de perte à indexer des objets dans votre paquet.

Vous pouvez amoindrir ceci en encodant chaque index d'objet relativement au précédent. Pensez-y, nous itérons de gauche à droite sur notre tableau, donc les indices commencent à 0 et vont jusque MaxObjets - 1. Statistiquement parlant, vous avez toutes les chances d'avoir des objets proches les uns des autres et si l'index suivant est +1, ou même +10 ou +30 que le précédent, en moyenne, vous aurez besoin de bien moins de bits pour représenter cette différence que pour représenter un index absolu.

Voici une façon d'encoder l'index de l'objet sous forme d'entier relativement à l'index du précédent objet, tout en dépensant moins de bits sur des valeurs statistiquement plus probables (par exemple de petites différences entre des indices successifs, ou à l'inverse de grandes différences) :

Delta encoding des index

1. template <typename Stream>

2. bool serialize_object_index_internal( Stream & stream,

3. int & previous,

4. int & current )

5. {

6. uint32_t difference;

7. if ( Stream::IsWriting )

8. {

9. assert( previous < current );

10. difference = current - previous;

11. assert( difference > 0 );

12. }

14. // +1 (1 bit)

15. bool plusOne;

16. if ( Stream::IsWriting )

17. plusOne = difference == 1;

18. serialize_bool( stream, plusOne );

19. if ( plusOne )

20. {

21. if ( Stream::IsReading )

22. current = previous + 1;

23. previous = current;

24. return true;

25. }

27. // [+2,5] -> [0,3] (2 bits)

28. bool twoBits;

29. if ( Stream::IsWriting )

30. twoBits = difference <= 5;

31. serialize_bool( stream, twoBits );

32. if ( twoBits )

33. {

34. serialize_int( stream, difference, 2, 5 );

35. if ( Stream::IsReading )

36. current = previous + difference;

37. previous = current;

38. return true;

39. }

41. // [6,13] -> [0,7] (3 bits)

42. bool threeBits;

43. if ( Stream::IsWriting )

44. threeBits = difference <= 13;

45. serialize_bool( stream, threeBits );

46. if ( threeBits )

47. {

48. serialize_int( stream, difference, 6, 13 );

49. if ( Stream::IsReading )

50. current = previous + difference;

51. previous = current;

52. return true;

53. }

55. // [14,29] -> [0,15] (4 bits)

56. bool fourBits;

57. if ( Stream::IsWriting )

58. fourBits = difference <= 29;

59. serialize_bool( stream, fourBits );

60. if ( fourBits )

61. {

62. serialize_int( stream, difference, 14, 29 );

63. if ( Stream::IsReading )

64. current = previous + difference;

65. previous = current;

66. return true;

67. }

69. // [30,61] -> [0,31] (5 bits)

70. bool fiveBits;

71. if ( Stream::IsWriting )

72. fiveBits = difference <= 61;

73. serialize_bool( stream, fiveBits );

74. if ( fiveBits )

75. {

76. serialize_int( stream, difference, 30, 61 );

77. if ( Stream::IsReading )

78. current = previous + difference;

79. previous = current;

80. return true;

81. }

83. // [62,125] -> [0,63] (6 bits)

84. bool sixBits;

85. if ( Stream::IsWriting )

86. sixBits = difference <= 125;

87. serialize_bool( stream, sixBits );

88. if ( sixBits )

89. {

90. serialize_int( stream, difference, 62, 125 );

91. if ( Stream::IsReading )

92. current = previous + difference;

93. previous = current;

94. return true;

95. }

97. // [126,MaxObjects+1]

98. serialize_int( stream, difference, 126, MaxObjects + 1 );

99. if ( Stream::IsReading )

100. current = previous + difference;

101. previous = current;

102. return true;

103. }

105. template <typename Stream>

106. bool serialize_scene_d( Stream & stream, Scene & scene )

107. {

108. int previous_index = -1;

110. if ( Stream::IsWriting )

111. {

112. for ( int i = 0; i < MaxObjects; ++i )

113. {

114. if ( !scene.objects[i].send )

115. continue;

116. write_object_index( stream, previous_index, i );

117. write_object( stream, scene.objects[i] );

118. }

119. write_object_index( stream, previous_index, MaxObjects );

120. }

121. else

122. {

123. while ( true )

124. {

125. int index;

126. read_object_index( stream, previous_index, index );

127. if ( index == MaxObjects )

128. break;

129. read_object( stream, scene.objects[index] );

130. }

131. }

132. return true;

133. }

En général pas mal de bande passante sera économisée parce que les indices d'objets auront tendance à être regroupés. Au cas où l'objet suivant est envoyé, il s'agit d'un unique bit pour l'index suivant qui vaudra +1 et 5 bits par index pour +2 à +5. En moyenne on aura entre 2x à 3x moins de surcoût pour nos index. Mais remarquez que des indices plus élevés et éloignés coûteront beaucoup plus cher pour chaque index qu'un encodage non relatif (12 bits par index). Ce qui semble mauvais ne l'est pas parce que, pensez-y, même si vous atteignez le « pire cas » (indices d'objets séparés uniformément de 128 index), combien d'objets pouvez-vous avoir dans un tableau de 4000 objets ? Juste 32. Pas d'inquiétude !

VII - ID de protocole, CRC32 et vérifications de sérialisation

À ce moment, vous devez vraiment vous poser des questions. Waouh ! Ce truc semble vraiment fragile. C'est un flux binaire totalement arbitraire. Un château de cartes. Que se passe-t-il si par un moyen quelconque read et write se désynchronisent ? Si quelqu'un envoie des paquets contenant des octets aléatoires à votre serveur. Combien de temps avant qu'une suite d'octets entraîne un crash de votre application ?

J'ai de bonnes nouvelles pour vous et le reste de l'industrie du jeu vidéo puisque la plupart des serveurs reposent sur ce procédé. Il y a des techniques que vous pouvez utiliser pour réduire ou virtuellement éliminer la possibilité de corruption de données une fois les données sérialisées.

La première technique est d'inclure un identifiant de protocole dans votre paquet. Typiquement, les quatre premiers octets peuvent être utilisés pour insérer une valeur raisonnablement rare et unique, peut-être 0x123456789 parce que personne d'autre ne pensera jamais à l'utiliser. Mais sérieusement, mettez un hash de votre identifiant de protocole et votre version de protocole dans les premiers 32 bits de chaque paquet et vous vous en sortirez bien. Au moins si un paquet est envoyé à votre port d'une autre application (souvenez-vous que les paquets UDP peuvent venir de n'importe quelle combinaison IP/port à tout moment) vous pouvez facilement l'écarter :

1. [protocol id] (32bits)

2. (packet data)

Le niveau suivant de protection est d'inclure un CRC32 de votre paquet dans l'en-tête. Ça vous permettra de détecter les paquets corrompus (ça arrive, rappelez-vous que le checksum IP n'est que 16 bits, et beaucoup d'informations ne seront pas vérifiées par un checksum de 16 bits…). Maintenant l'en-tête de votre paquet ressemble à ça :

1. [protocol id] (32bits)

2. [crc32] (32bits)

3. (packet data)

Là vous devez grincer des dents. Attends. Je dois utiliser 8 octets par paquet juste pour implémenter mon propre checksum et identifiant de protocole ? Et bien, vous ne devez pas. Vous pouvez vous inspirer du checksum IPv4, et faire de l'identifiant de protocole un préfixe magique. Par exemple : vous ne l'envoyez pas, mais si l'émetteur et le receveur connaissent tous deux l'identifiant de protocole et que le CRC32 est calculé comme si le paquet était préfixé par l'identifiant de protocole, le CRC32 sera incorrect si l'émetteur n'a pas le même identifiant de protocole que le receveur, permettant de sauver quatre octets par paquet :

1. [protocol id] (32bits)   // not actually sent, but used to calc crc32

2. [crc32] (32bits)

3. (packet data)

Bien sûr le CRC32 ne protège que contre les malformations aléatoires de paquet, et n'est pas une protection contre un émetteur mal intentionné qui peut aisément modifier ou construire un paquet malveillant puis ajuster correctement le CRC32 dans les quatre premiers bits. Pour vous protéger face à ça, vous devrez utiliser une fonction de hash plus cryptographiquement sécurisée éventuellement combinée avec un échange de clés secrètes entre le client et le serveur via HTTPS effectué par le serveur de matchmaking avant que le client ne tente de se connecter au serveur de jeux (une clé différente pour chaque client, connue uniquement par le serveur et ce client particulier).

Une technique pour finir, peut-être plus une vérification d'erreur de programmation de votre côté et émetteurs mal intentionnés (bien que redondante une fois que vous chiffrez et signez votre paquet) est la vérification de sérialisation. Typiquement, quelque part au milieu du paquet, avant ou après une section de sérialisation compliquée, ajoutez juste une valeur entière connue de 32 bits, et vérifiez qu'elle est lue à la réception avec la même valeur. Si la valeur est incorrecte, abandonnez la lecture et rejetez le paquet.

J'aime faire ça entre les sections de mon paquet quand je les écris, au moins je sais quelle partie de la sérialisation de mon paquet s'est désynchronisée entre lecture et écriture pendant le développement de mon protocole (ça va arriver quoi que vous fassiez pour l'éviter…). Une autre astuce cool que j'aime utiliser est de sérialiser une vérification de protocole à la toute fin du paquet. C'est super, super utile parce que ça aide à détecter les troncations de paquets (comme l'ignoble troncation little endian vs big endian du dernier mot de l'article précédent).

Le paquet ressemble maintenant à ça :

1. [protocol id] (32bits)// non envoyé, mais utilisée pour calculer le crc32

2. [crc32] (32bits)

3. (packet data)

4. [end of packet serialize check] (32 bits)

Vous pouvez juste retirer ces vérifications de protocole dans votre application finale si vous voulez, particulièrement si vous avez un bon chiffrement et une bonne signature de paquets, puisqu'elles ne devraient plus être nécessaires à ce stade.

VIII - Remerciements

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

Article précédent

Article suivant

<< Lecture et écriture des paquets

Fragmentation et réassemblage des paquets >>