6.1 Introduction : le réseau comme source d’incertitude
Le réseau est, par nature, l’antithèse du déterminisme. La latence varie imprévisiblement (jitter), les paquets arrivent dans le désordre ou sont perdus, et la bande passante est partagée avec des flux incontrôlables. L’infrastructure réseau d’un moteur déterministe doit masquer cette incertitude tout en préservant la cohérence de l’état synchronisé entre toutes les machines.1
Pour atteindre des latences sous-millisecondes requises par la VR multijoueur, LplKernel implémente un Kernel Bypass réseau inspiré de l’architecture DPDK (Data Plane Development Kit). Au lieu de subir le coût des interruptions matérielles et des multiples copies en mémoire de la pile TCP/IP classique, le pilote réseau (ex: Intel 8254x ou i217) mappe directement ses anneaux de descripteurs matériels (Descriptor Rings) dans une zone mémoire physique contiguë (allouée via le Buddy Allocator sous forme de MBUFs). Le CPU scrute ces anneaux en boucle (Polling), ce qui réalise un transfert Zero-Copy DMA absolu. Couplé au Receive Side Scaling (RSS), la carte réseau hache elle-même les paquets UDP entrants et les distribue matériellement sur les différents cœurs CPU du système SMP.
6.2 Modèles de synchronisation approfondis
6.2.1 Lockstep déterministe
Le Lockstep est le modèle le plus ancien et le plus strict. Tous les clients transmettent leurs inputs pour le tick ; chaque client attend d’avoir reçu les inputs de tous les autres clients avant de simuler le tick . La simulation étant déterministe (Chapitre 3), l’état résultant est identique sur toutes les machines.
L’avantage : la bande passante est minimale, seuls les inputs transitent, pas l’état du monde (quelques octets par tick au lieu de kilooctets). D’où son usage dans les RTS (StarCraft, Age of Empires), où l’on simule des milliers d’unités, ce qui rend la synchronisation d’état impraticable.
L’inconvénient : la latence perçue est celle du joueur le plus lent. Si un joueur a un ping de 200 ms, tous les joueurs subissent 200 ms de délai.
6.2.2 Rollback Netcode (GGPO)
Le Rollback Netcode, formalisé par la bibliothèque GGPO (Good Game Peace Out) pour les jeux de combat, est un mécanisme sophistiqué en trois temps :
- Prédiction : Le client simule immédiatement en utilisant le dernier input connu de l’adversaire (ou une prédiction basée sur l’historique).
- Réception : Lorsqu’un input distant arrive (potentiellement en retard), le client vérifie s’il diffère de la prédiction.
- Rollback et resimulation : Si l’input réel diffère de la prédiction, le client revient en arrière au tick où la divergence a eu lieu, applique l’input correct, puis resimule tous les ticks jusqu’au tick courant.
void RollbackNetcode::ProcessRemoteInput(uint32_t tick, Input remoteInput) {
if (tick < m_currentTick) {
// Input en retard — rollback nécessaire
RestoreSnapshot(tick); // Restaurer l'état au tick T
m_inputBuffer[tick] = remoteInput; // Corriger l'input
// Resimulation rapide : du tick T au tick courant
for (uint32_t t = tick; t < m_currentTick; ++t) {
SimulateFixedStep(m_inputBuffer[t]);
SaveSnapshot(t + 1);
}
}
}
Ce mécanisme exige :
- Snapshots rapides : La sauvegarde/restauration de l’état doit être quasi-instantanée. Les allocateurs Arena (Chapitre 2) sont idéaux : un
memcpydu bloc complet suffit. - Simulation rapide : La resimulation de N ticks doit être plus rapide que le temps réel. Les systèmes ECS (Chapitre 4) avec itération SoA permettent de resimuler 8+ ticks dans le budget d’une frame.
- Déterminisme absolu : La resimulation doit produire exactement le même état qu’une simulation directe.
6.3 Sérialisation : le Bitstream
6.3.1 Pourquoi pas memcpy ?
La sérialisation naïve par memcpy d’une structure C++ sur le réseau est problématique :
- Padding : Le compilateur insère des octets de bourrage pour l’alignement, gaspillant de la bande passante.
- Endianness : Un serveur big-endian (rare mais possible en embarqué) et un client little-endian interpréteront les octets différemment.
- Portabilité : La taille des types (
int,long, pointeurs) varie entre les architectures.
6.3.2 Le Bitstream
Un Bitstream est un tampon binaire qui sérialise les données bit par bit, sans padding ni gaspillage :
class Bitstream {
std::vector<uint8_t> m_buffer;
size_t m_bitOffset = 0;
public:
void WriteBits(uint32_t value, uint8_t numBits) {
for (int i = numBits - 1; i >= 0; --i) {
size_t byteIndex = m_bitOffset / 8;
size_t bitIndex = m_bitOffset % 8;
if (byteIndex >= m_buffer.size())
m_buffer.push_back(0);
if (value & (1u << i))
m_buffer[byteIndex] |= (1u << (7 - bitIndex));
++m_bitOffset;
}
}
uint32_t ReadBits(uint8_t numBits) {
uint32_t result = 0;
for (int i = numBits - 1; i >= 0; --i) {
size_t byteIndex = m_bitOffset / 8;
size_t bitIndex = m_bitOffset % 8;
if (m_buffer[byteIndex] & (1u << (7 - bitIndex)))
result |= (1u << i);
++m_bitOffset;
}
return result;
}
};
6.3.3 Bit-packing et quantization
Pour minimiser la bande passante, les données sont compressées sémantiquement :
- Bit-packing : un booléen utilise 1 bit (pas 8). Un angle de rotation (0-360°) quantifié sur 10 bits donne une résolution de 0.35°, suffisante pour le gameplay, avec 10 bits au lieu de 32.
- Quantization : Les positions flottantes sont converties en entiers avec une résolution fixe. Une position dans un monde de 1000m, quantifiée sur 16 bits, donne une résolution de 15 mm.
- Delta compression : Seules les différences par rapport à l’état précédent sont envoyées. Si une entité n’a pas bougé, 0 bits sont transmis pour sa position.2
6.4 State Hashing et détection de désynchronisation
6.4.1 Le hash d’état
Pour détecter les désynchronisations entre le serveur et les clients (dues à un bug, une corruption mémoire ou une tentative de triche), l’état de la simulation est hashé à chaque tick. Le hash est calculé sur les données critiques : positions, vitesses, états d’entités.
uint64_t HashGameState(const GameState& state) {
uint64_t hash = 0xcbf29ce484222325ULL; // FNV-1a offset basis
for (const auto& entity : state.entities) {
// Hash chaque composant déterministe
hash ^= HashBytes(&entity.position, sizeof(entity.position));
hash *= 0x100000001b3ULL; // FNV-1a prime
hash ^= HashBytes(&entity.velocity, sizeof(entity.velocity));
hash *= 0x100000001b3ULL;
}
return hash;
}
Les clients transmettent périodiquement leur hash au serveur. Si un hash client diverge du hash serveur pour le même tick, une désynchronisation est détectée. Le serveur peut alors forcer une resynchronisation complète ou bloquer le client suspect.
6.4.2 Diagnostic de désynchronisation
Lorsqu’une désynchronisation est détectée, le système enregistre les snapshots complets des états client et serveur au tick divergent. On peut alors faire un diagnostic post-mortem : comparer chaque composant pour identifier la source de la divergence (erreur d’arrondi, race condition, bug logique).
6.5 Le Replay System
Le Command Pattern (Chapitre 4) transforme chaque input joueur en un objet sérialisable. En enregistrant la séquence chronologique de toutes les commandes de tous les joueurs, le moteur peut rejouer une partie complète :
struct ReplayFrame {
uint32_t tick;
uint8_t playerIndex;
Input input; // Sérialisé en Bitstream
};
std::vector<ReplayFrame> replayLog;
// Enregistrement
void RecordInput(uint32_t tick, uint8_t player, Input input) {
replayLog.push_back({tick, player, input});
}
// Rejeu
void Replay(const std::vector<ReplayFrame>& log) {
GameState state = InitialState();
for (const auto& frame : log) {
while (state.tick < frame.tick)
SimulateFixedStep(state, {}); // Ticks vides
ApplyInput(state, frame.playerIndex, frame.input);
}
}
Le replay est rendu possible par le déterminisme : les mêmes inputs reproduisent identiquement les mêmes résultats. C’est un outil de débogage puissant et un mécanisme anti-triche (le serveur peut rejouer l’enregistrement pour vérifier la validité des actions signalées).
6.6 Communication inter-threads : SPSC lock-free
Le thread réseau (réception de paquets) et le thread de simulation ne doivent jamais se bloquer mutuellement via un mutex. La solution, c’est la queue SPSC lock-free (Single-Producer, Single-Consumer) détaillée au Chapitre 2 (Ring Buffer).
Le flux est :
graph LR
Net["Network (Thread 1)
UDP packet receive / send"]
Sim["Simulation (Thread 2)
Read inputs / produce state"]
Net -->|"SPSC Queue: received inputs"| Sim
Sim -->|"SPSC Queue: produced state"| Net
Chaque queue est un Ring Buffer avec des atomiques acquire/release. Le thread réseau ne bloque jamais le thread de simulation, et vice versa : une propriété décisive pour tenir le pas de temps fixe sous les 16.67 ms.
6.7 Synthèse
| Composant | Technologie | Rôle |
|---|---|---|
| Sérialisation | Bitstream + bit-packing | Compression maximale des paquets |
| Synchronisation | Rollback Netcode + Lockstep (hybride) | Latence minimale + déterminisme |
| Intégrité | State Hashing (FNV-1a) | Détection de désynchronisation |
| Diagnostic | Replay System (Command Pattern) | Rejeu déterministe + anti-triche |
| Concurrence | SPSC Lock-Free Ring Buffers | Zéro contention entre threads |