Comment implémenter des modules C ++ échangeables à chaud?


39

Les temps d’itération rapides sont la clé du développement de jeux, bien plus que des graphismes sophistiqués et des moteurs dotés de nombreuses fonctionnalités à mon avis. Pas étonnant que beaucoup de petits développeurs choisissent des langages de script.

La manière d'Unity 3D de pouvoir mettre en pause un jeu et de modifier des ressources ET du code, puis de continuer et de faire en sorte que les modifications prennent effet immédiatement est absolument géniale pour cela. Ma question est la suivante: quelqu'un a-t-il mis en œuvre un système similaire sur les moteurs de jeu C ++?

J'ai entendu dire que certains moteurs très haut de gamme le font, mais je suis plus intéressé par savoir s'il existe un moyen de le faire dans un moteur ou un jeu développé localement.

De toute évidence, il y aurait des compromis, et je ne peux pas imaginer que vous puissiez simplement recompiler votre code pendant que le jeu est en pause, le recharger et le faire fonctionner dans toutes les circonstances ou sur toutes les plateformes.

Mais peut-être est-il possible pour la programmation AI et les modules logiques à niveau simple. Ce n'est pas comme si je voulais faire ça dans n'importe quel projet à court (ou long terme), mais j'étais curieux.

Réponses:


26

C'est certainement possible, mais pas avec votre code C ++ typique; vous devez créer une bibliothèque de style C que vous pouvez relier et recharger dynamiquement au moment de l'exécution. Pour rendre cela possible, la bibliothèque doit contenir tous les états dans un pointeur opaque pouvant être fourni à la bibliothèque lors du rechargement.

Timothy Farrar parle de son approche:

"Pour le développement, le code est compilé en tant que bibliothèque, un petit programme de chargement en crée une copie et charge la copie de bibliothèque pour exécuter le programme. Le programme alloue toutes les données au démarrage et utilise un seul pointeur pour référencer les données. Je n'utilise rien de global sauf des données en lecture seule. Lorsque le programme est en cours d'exécution, la bibliothèque d'origine peut être recompilée. Ensuite, en une pression, le programme retourne au chargeur et renvoie ce pointeur de données unique. Le chargeur fait une copie de la nouvelle bibliothèque, charge la copie et passe le pointeur de données au nouveau code. Le moteur continue ensuite là où il s’est arrêté. " ( source originale , maintenant disponible à travers l'archive Web )


Je préfère ceci à la réponse de Blair, alors que vous répondez réellement à la question.
Jonathan Dickinson

15

Le remplacement à chaud est un problème très, très difficile à résoudre avec un code binaire. Même si votre code est placé dans des bibliothèques de liens dynamiques distinctes, votre code doit garantir qu'il n'y a pas de références directes à des fonctions en mémoire (car l'adresse des fonctions peut changer avec une recompilation). Cela signifie essentiellement que vous n'utilisez pas de fonctions virtuelles, et tout ce qui utilise des pointeurs de fonction le fait via une sorte de tableau de répartition.

Des trucs poilus et contraignants. Il est préférable d'utiliser un langage de script pour le code qui nécessite une itération rapide.

Au travail, notre base de code actuelle est un mélange de C ++ et de Lua. Lua n'est pas non plus une petite partie de notre projet, c'est presque 50/50. Nous avons mis en place un rechargement à la volée de Lua, qui vous permet de modifier une ligne de code, de recharger et de continuer. En fait, vous pouvez le faire afin de corriger les bugs qui se produisent dans le code Lua, sans avoir à redémarrer le jeu!


3
Un autre point important est que même si vous n’avez pas l’intention de conserver un système dans un script, si vous envisagez d’en itérer beaucoup plus tôt, il peut être tout à fait raisonnable de commencer par Lua, puis de passer au C ++ plus tard, lorsque vous avez besoin du logiciel. des performances supplémentaires et le taux d’itération a ralenti. L’inconvénient évident ici est que vous finissez par porter le code, mais la partie la plus difficile est bien de comprendre la logique: le code s’écrit presque tout seul une fois que vous avez compris cette partie.
Logan Kincaid

15

(Vous voudrez peut-être connaître le terme "correction de singe" ou "frappe de canard" si ce n'est que pour l'image mentale humoristique.)

Cela dit: si votre objectif est de réduire le temps d’itération pour les changements de «comportement», essayez des approches qui vous mèneront presque jusqu'au bout, et combinez-les pour en permettre davantage à l’avenir.

(Cela sera un peu tangent, mais je vous promets que ça reviendra!)

  • Commencez par les données et commencez petit: rechargez aux limites ("niveaux" ou autres), puis utilisez les fonctionnalités du système d'exploitation pour obtenir des notifications de modification de fichier ou interrogez-les simplement régulièrement.
  • (Pour les points bonus et les temps de chargement réduits (encore une fois, temps d'itération décroissant), examinez la cuisson des données .)
  • Les scripts sont des données et vous permettent d'itérer le comportement. Si vous utilisez un langage de script, vous avez maintenant la possibilité de recharger ces scripts, interprétés ou compilés. Vous pouvez également connecter votre interprète à une console de jeu, à une prise réseau ou similaire, pour une flexibilité accrue lors de l'exécution.
  • Le code peut aussi être une donnée : votre compilateur peut prendre en charge les superpositions , les bibliothèques partagées, les DLL, etc. Vous pouvez donc maintenant choisir un moment "sûr" pour décharger et recharger une superposition ou une DLL, qu'elle soit manuelle ou automatique. Les autres réponses vont dans les détails ici. Notez que certaines variantes de ce type peuvent perturber la vérification de signature cryptographique, le bit NX (non-exécution) ou des mécanismes de sécurité similaires.
  • Envisagez un système de sauvegarde / chargement versionné en profondeur, avec versions . Si vous pouvez enregistrer et restaurer votre état de manière robuste, même en cas de changement de code, vous pouvez arrêter votre jeu et le redémarrer avec une nouvelle logique exactement au même endroit. Plus facile à dire qu'à faire, mais c'est faisable, et c'est nettement plus facile et plus portable que de fouiller la mémoire pour changer les instructions.
  • Selon la structure et le déterminisme de votre jeu, vous pourrez peut-être enregistrer et lire . Si cet enregistrement est juste au-dessus des "commandes de jeu" (pensez à un jeu de cartes, par exemple), vous pouvez modifier tout le code de rendu souhaité et rejouer l'enregistrement pour voir vos modifications. Pour certains jeux, cela est aussi "facile" que d'enregistrer des paramètres de départ (par exemple, une graine aléatoire) puis des actions de l'utilisateur. Pour certains, c'est beaucoup plus compliqué.
  • Faire des efforts pour réduire le temps de compilation . En combinaison avec les systèmes de sauvegarde / chargement ou d'enregistrement / lecture susmentionnés, ou même avec des superpositions ou des DLL, cela peut réduire votre délai d'exécution plus que toute autre chose.

Bon nombre de ces points sont utiles même si vous ne parvenez pas à recharger des données ou du code.

Anecdotes à l'appui:

Sur un grand PC RTS (environ 120 personnes, principalement C ++), il existait un système extrêmement puissant de sauvegarde d'état, utilisé à au moins trois fins:

  • Une sauvegarde "peu profonde" a été alimentée non pas sur disque, mais sur un moteur CRC, afin de garantir que les parties multijoueurs restent en simulation avec un pas de contrôle complet un CRC toutes les 10-30 images; cela a permis à personne de tricher et d'attraper des bogues désynchrones quelques images plus tard
  • Si et quand un bogue de désynchronisation multijoueur se produisait, une sauvegarde très profonde était effectuée à chaque image, puis de nouveau transmise au moteur CRC, mais cette fois, le moteur CRC générait de nombreux CRC, chacun pour des lots d'octets plus petits. De cette manière, il pourrait vous dire exactement quelle partie de l'état a commencé à diverger dans la dernière image. Nous avons détecté une vilaine différence en "mode virgule flottante par défaut" entre les processeurs AMD et Intel utilisant cette méthode.
  • Une sauvegarde de profondeur normale pourrait ne pas enregistrer, par exemple, l'image exacte de l'animation jouée par votre unité, mais elle obtiendrait la position, la santé, etc. de toutes vos unités, vous permettant ainsi de sauvegarder et de reprendre à tout moment pendant le jeu.

Depuis, j'ai utilisé l'enregistrement / la lecture déterministe sur un jeu de cartes C ++ et Lua pour la DS. Nous nous sommes connectés à l'API que nous avons conçue pour l'IA (côté C ++) et avons enregistré toutes les actions de l'utilisateur et de l'IA. Nous avons utilisé cette fonctionnalité dans le jeu (pour rediffuser le joueur), mais aussi pour diagnostiquer les problèmes: en cas de crash ou de comportement étrange, il suffisait de récupérer le fichier de sauvegarde et de le lire dans une version de débogage.

Depuis lors, j'ai également utilisé plus de quelques fois les incrustations, et nous les avons combinées avec notre système de "parcourir automatiquement ce répertoire et de télécharger du nouveau contenu sur le terminal". Tout ce que nous aurions à faire, c’est de quitter la cinématique / niveau / peu importe et de revenir, et non seulement les nouvelles données (images-objets, agencement de niveaux, etc.) seraient chargées, mais également tout nouveau code inséré dans la superposition. Malheureusement, cela devient beaucoup plus difficile avec les ordinateurs de poche les plus récents en raison de la protection contre la copie et des mécanismes anti-piratage qui traitent spécialement le code. Nous le faisons toujours pour les scripts Lua cependant.

Dernier point mais non le moindre: vous pouvez (et j'ai, dans de très petites circonstances spécifiques) faire un peu de frappe de canard en corrigeant directement les codes d'opération d'instructions. Cela fonctionne mieux si vous êtes sur une plate-forme fixe et un compilateur, et parce que c'est presque impossible à maintenir, très sujet aux bogues et limité dans ce que vous pouvez accomplir rapidement, je ne l'utilise surtout que pour réacheminer le code pendant le débogage. Il ne vous enseigner un enfer beaucoup sur votre architecture de jeu d'instructions pressé, cependant.


6

Vous pouvez faire quelque chose comme des modules remplaçants à chaud au moment de l'exécution, les implémentant en tant que bibliothèques de liens dynamiques (ou bibliothèques partagées sous UNIX), et en utilisant dlopen () et dlsym () pour charger des fonctions de manière dynamique à partir de la bibliothèque.

Pour Windows, les équivalents sont LoadLibrary et GetProcAddress.

Ceci est une méthode C, et présente quelques pièges en l’utilisant en C ++, vous pouvez en savoir plus ici .


ce guide est la clé pour créer des bibliothèques
échangeables

4

Si vous utilisez Visual Studio C ++, vous pouvez en fait mettre en pause et recompiler votre code dans certaines circonstances. Visual Studio prend en charge Modifier et continuer . Attachez-vous à votre jeu dans le débogueur, arrêtez-le à un point d'arrêt, puis modifiez le code après votre point d'arrêt. Si vous devez enregistrer puis continuer, Visual Studio tentera de recompiler le code, de le réinsérer dans l'exécutable en cours d'exécution et de continuer. Si tout se passe bien, les modifications apportées s'appliqueront au jeu en cours d'exécution sans qu'il soit nécessaire d'effectuer un cycle complet de compilation-test-construction. Cependant, ceci empêchera ceci de fonctionner:

  1. Changer les fonctions de rappel ne fonctionnera pas correctement. E & C fonctionne en ajoutant un nouveau bloc de code et en modifiant le tableau des appels pour qu'il pointe vers le nouveau bloc. Pour les rappels et tout ce qui utilise des pointeurs de fonction, il continuera d'exécuter les anciens appels non modifiés. Pour résoudre ce problème, vous pouvez avoir une fonction de rappel du wrapper qui appelle une fonction statique
  2. La modification des fichiers d'en-tête ne fonctionnera presque jamais. Ceci est conçu pour modifier les appels de fonction réels.
  3. Diverses constructions de langage provoqueront un échec mystérieux. D'après mon expérience personnelle, pré-déclarer des choses comme des énumérations le fera souvent.

J'ai utilisé Edit and Continue pour améliorer considérablement la vitesse d’itérations telles que l’itération d’UI. Supposons que votre interface utilisateur fonctionne entièrement en code, sauf que vous avez accidentellement inversé l'ordre de tirage de deux boîtes afin que vous ne puissiez rien voir. En modifiant 2 lignes de code en direct, vous pouvez économiser un cycle de compilation / compilation / test de 20 minutes pour vérifier un correctif d'interface utilisateur trivial.

Ce n'est PAS une solution complète pour un environnement de production, et j'ai trouvé la meilleure solution pour transférer autant de votre logique que possible dans des fichiers de données, puis pour rendre ces fichiers rechargeables.


quel cycle de compilation prend 20 minutes?!? Je préfère me tirer moi
JqueryToAddNumbers

3

Comme d'autres l'ont dit, c'est un problème difficile, car lier dynamiquement le C ++. Mais c'est un problème résolu - vous avez peut-être entendu parler de COM ou de l'un des noms marketing qui lui ont été appliqués au fil des ans: ActiveX.

COM est un peu un mauvais nom du point de vue du développeur car il peut être très difficile d'implémenter des composants C ++ exposant leurs fonctionnalités (bien que cela soit simplifié avec ATL - la bibliothèque de modèles ActiveX). Du point de vue du consommateur, il a mauvaise réputation car les applications qui l'utilisent, par exemple pour incorporer une feuille de calcul Excel dans un document Word ou un diagramme Visio dans une feuille de calcul Excel, avaient tendance à se bloquer un peu plus tôt dans la journée. Et cela revient aux mêmes problèmes - même avec toutes les indications fournies par Microsoft, COM / ActiveX / OLE était / est difficile à obtenir correctement.

Je soulignerai que la technologie de COM n'est pas intrinsèquement mauvaise. Tout d'abord, DirectX utilise des interfaces COM pour exposer ses fonctionnalités, ce qui fonctionne assez bien, de même qu'une multitude d'applications intégrant Internet Explorer à l'aide de son contrôle ActiveX. Deuxièmement, c’est l’un des moyens les plus simples de lier dynamiquement du code C ++: une interface COM est essentiellement une simple classe virtuelle. Bien qu'il ait un IDL comme CORBA, vous n'êtes pas obligé de l'utiliser, surtout si les interfaces que vous définissez ne sont utilisées que dans votre projet.

Si vous n'écrivez pas pour Windows, ne pensez pas que COM ne mérite pas d'être envisagé. Mozilla l'a ré-implémenté dans sa base de code (utilisée dans le navigateur Firefox) car ils avaient besoin d'un moyen de composant leur code C ++.


2

Il existe une implémentation de C ++ compilé à l'exécution pour le code de jeu ici . De plus, je sais qu’au moins un moteur de jeu propriétaire fait de même. C'est délicat, mais faisable.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.