Wie kann man Hot-Swap-fähige C ++ - Module implementieren?

39

Schnelle Iterationszeiten sind der Schlüssel zur Entwicklung von Spielen, meiner Meinung nach weit mehr als ausgefallene Grafiken und Engines mit einer Fülle von Funktionen. Kein Wunder, dass viele kleine Entwickler Skriptsprachen auswählen.

Die Unity 3D-Methode, mit der Sie ein Spiel pausieren und Assets UND Code ändern können, um dann fortzufahren und die Änderungen sofort wirksam werden zu lassen, ist hierfür absolut geeignet. Meine Frage ist, hat jemand ein ähnliches System auf C ++ - Game-Engines implementiert?

Ich habe gehört, dass es einige wirklich Super-High-End-Engines gibt, aber ich bin mehr daran interessiert, herauszufinden, ob es eine Möglichkeit gibt, dies in einer selbst gebauten Engine oder in einem Spiel zu tun.

Offensichtlich würde es Kompromisse geben, und ich kann mir nicht vorstellen, dass Sie Ihren Code einfach neu kompilieren können, während das Spiel pausiert, neu geladen wird und unter allen Umständen oder auf jeder Plattform funktioniert.

Aber vielleicht ist es für AI-Programmierung und einfache Logikmodule möglich. Es ist nicht so, dass ich das kurzfristig (oder langfristig) in einem Projekt machen möchte, aber ich war neugierig.

Böser Engel
quelle

Antworten:

26

Dies ist definitiv möglich, obwohl dies mit einem typischen C ++ - Code nicht möglich ist. Sie müssen eine Bibliothek im C-Stil erstellen, die Sie zur Laufzeit dynamisch verknüpfen und neu laden können. Um dies zu ermöglichen, muss die Bibliothek den gesamten Status innerhalb eines undurchsichtigen Zeigers enthalten , der der Bibliothek beim erneuten Laden zur Verfügung gestellt werden kann.

Timothy Farrar diskutiert seinen Ansatz:

Für die Entwicklung wird der Code als Bibliothek kompiliert, ein kleines Ladeprogramm erstellt eine Kopie der Bibliothek und lädt die Bibliothekskopie, um das Programm auszuführen. Das Programm weist alle Daten beim Start zu und verwendet einen einzelnen Zeiger, um auf Daten zu verweisen. Ich verwende nichts Globales außer Nur-Lese-Daten. Während das Programm ausgeführt wird, kann die ursprüngliche Bibliothek neu kompiliert werden. Dann kehrt das Programm mit einem Tastendruck zum Ladeprogramm zurück und gibt diesen einzelnen Datenzeiger zurück. Der Ladeprogramm erstellt eine Kopie der neuen Bibliothek. Lädt die Kopie und übergibt den Datenzeiger an den neuen Code. Dann fährt die Engine dort fort, wo sie aufgehört hat. " ( Originalquelle , jetzt im Webarchiv verfügbar )

Jason Kozak
quelle
Ich bevorzuge dies Blairs Antwort, da du die Frage tatsächlich beantwortest.
Jonathan Dickinson
15

Hot-Swapping ist ein sehr, sehr schwieriges Problem, das mit Binärcode zu lösen ist. Selbst wenn Ihr Code in separaten Dynamic Link-Bibliotheken abgelegt wird, muss Ihr Code sicherstellen, dass keine direkten Verweise auf Funktionen im Speicher vorhanden sind (da sich die Adresse von Funktionen bei einer Neukompilierung ändern kann). Dies bedeutet im Wesentlichen, keine virtuellen Funktionen zu verwenden, und alles, was Funktionszeiger verwendet, tut dies über eine Art Dispatch-Tabelle.

Behaarte, einschränkende Sachen. Es ist besser, eine Skriptsprache für Code zu verwenden, der eine schnelle Iteration erfordert.

Bei der Arbeit ist unsere aktuelle Codebasis eine Mischung aus C ++ und Lua. Lua ist auch kein kleiner Teil unseres Projekts - es ist fast eine 50/50-Trennung. Wir haben das direkte Neuladen von Lua implementiert, sodass Sie eine Codezeile ändern, neu laden und weitermachen können. In der Tat können Sie dies tun, um Absturzfehler zu beheben, die im Lua-Code auftreten, ohne das Spiel neu zu starten!

Blair Holloway
quelle
3
Ein weiterer wichtiger Punkt ist, dass, auch wenn Sie nicht vorhaben, ein System im Skript zu belassen, es durchaus sinnvoll sein kann, in Lua zu beginnen, wenn Sie das benötigen, dann zu C ++ überzugehen zusätzliche Leistung und die Geschwindigkeit der Iteration hat sich verlangsamt. Der offensichtliche Nachteil dabei ist, dass Sie den Code am Ende portieren, aber oft ist es schwierig, die richtige Logik zu finden - der Code schreibt sich fast von selbst, sobald Sie diesen Teil herausgefunden haben.
Logan Kincaid
15

(Vielleicht möchten Sie etwas über den Begriff "Affenflicken" oder "Entenschlagen" wissen, wenn auch nur wegen des humorvollen mentalen Bildes.)

Abgesehen davon: Wenn Ihr Ziel darin besteht, die Iterationszeit für "Verhaltensänderungen" zu verkürzen, probieren Sie einige Ansätze aus, mit denen Sie den größten Teil des Weges dorthin finden, und kombinieren Sie sie gut, um in Zukunft mehr davon zu ermöglichen.

(Dies wird ein bisschen tangential ausgehen, aber ich verspreche, es wird wiederkommen!)

  • Beginnen Sie mit Daten und fangen Sie klein an: Laden Sie an Grenzen neu ("Ebenen" oder ähnliches), und arbeiten Sie sich dann daran, die Betriebssystemfunktionalität zu nutzen, um Benachrichtigungen über Dateiänderungen zu erhalten oder einfach regelmäßig abzufragen .
  • (Für Bonuspunkte und kürzere Ladezeiten (wiederum kürzere Iterationszeit) schauen Sie sich das Datenbacken an .)
  • Skripte sind Daten und ermöglichen es Ihnen, das Verhalten zu wiederholen. Wenn Sie eine Skriptsprache verwenden, haben Sie jetzt die Möglichkeit, Benachrichtigungen zu erhalten und diese Skripts neu zu laden, zu interpretieren oder zu kompilieren. Sie können Ihren Interpreter auch an eine In-Game-Konsole, einen Netzwerk-Socket oder ähnliches anschließen, um die Laufzeitflexibilität zu erhöhen.
  • Code kann auch Daten sein : Ihr Compiler unterstützt möglicherweise Overlays , gemeinsam genutzte Bibliotheken, DLLs oder ähnliches. So können Sie jetzt eine "sichere" Zeit zum Entladen und erneuten Laden einer Überlagerung oder DLL wählen, ob manuell oder automatisch. Die anderen Antworten gehen hier ins Detail. Beachten Sie, dass einige Varianten davon mit der Verschlüsselung der Signatur, dem NX-Bit (No-Execute) oder ähnlichen Sicherheitsmechanismen zu tun haben können .
  • Stellen Sie sich ein tiefgreifendes, versioniertes Speichern / Laden- System vor. Wenn Sie Ihren Status trotz Codeänderungen stabil speichern und wiederherstellen können, können Sie Ihr Spiel beenden und es mit neuer Logik genau zum gleichen Zeitpunkt neu starten. Leichter gesagt als getan, aber es ist machbar, und es ist ausgesprochen einfacher und tragbarer, als den Speicher zu durchsuchen, um Anweisungen zu ändern.
  • Abhängig von der Struktur und dem Determinismus Ihres Spiels können Sie möglicherweise Aufnahmen und Wiedergaben machen . Befindet sich diese Aufzeichnung nur über "Spielbefehle" (z. B. bei einem Kartenspiel), können Sie den gewünschten Rendering-Code ändern und die Aufzeichnung erneut abspielen, um Ihre Änderungen anzuzeigen. Für einige Spiele ist dies so "einfach" wie das Aufzeichnen einiger Startparameter (z. B. eines zufälligen Startwerts) und anschließender Benutzeraktionen. Für manche ist es viel komplizierter.
  • Bemühen Sie sich, die Kompilierzeit zu verkürzen . In Kombination mit den oben genannten Speicher- / Lade- oder Aufzeichnungs- / Wiedergabesystemen oder sogar mit Überlagerungen oder DLLs kann dies Ihren Turnaround mehr als jede andere Sache verringern.

Viele dieser Punkte sind auch dann von Vorteil, wenn Sie weder Daten noch Code vollständig neu laden müssen.

Unterstützende Anekdoten:

Auf einem großen PC-RTS (~ 120-köpfiges Team, meistens C ++) gab es ein unglaublich tiefgreifendes State-Save-System, das für mindestens drei Zwecke verwendet wurde:

  • Ein "flacher" Speichervorgang wurde nicht auf die Festplatte, sondern auf eine CRC-Engine übertragen, um sicherzustellen, dass die Multiplayer-Spiele in der Lock-Step-Simulation alle 10 bis 30 Frames einen CRC beibehalten. Dies stellte sicher, dass niemand betrog und einige Frames später Desync-Fehler entdeckte
  • Wenn ein Desync-Fehler im Multiplayer-Modus auftrat, wurde jedes Bild extra tief gespeichert und erneut an die CRC-Engine übertragen. Diesmal erzeugte die CRC-Engine jedoch viele CRCs, jeweils für kleinere Bytestapel. Auf diese Weise können Sie genau feststellen, welcher Teil des Zustands innerhalb des letzten Frames auseinander zu laufen begonnen hat. Wir haben einen unangenehmen Unterschied zwischen AMD- und Intel-Prozessoren im "Standard-Fließkomma-Modus" festgestellt.
  • Ein normaler Tiefenspeicher speichert möglicherweise nicht das genaue Bild der Animation, die Ihr Gerät abgespielt hat, aber er zeigt die Position, den Gesundheitszustand usw. aller Ihrer Geräte an, sodass Sie diese während des Spiels jederzeit speichern und fortsetzen können.

Ich habe seitdem deterministische Aufnahme / Wiedergabe für ein C ++ - und Lua-Kartenspiel für den DS verwendet. Wir haben uns in die API eingebunden, die wir für die KI entwickelt haben (auf der C ++ - Seite) und haben alle Benutzer- und KI-Aktionen aufgezeichnet. Wir haben diese Funktion im Spiel verwendet (um dem Spieler eine Wiederholung zu ermöglichen), aber auch um Probleme zu diagnostizieren: Wenn es zu einem Absturz oder einem merkwürdigen Verhalten kam, mussten wir nur die Sicherungsdatei abrufen und sie in einem Debugbuild wiedergeben.

Ich habe auch seitdem mehr als ein paar Mal Overlays verwendet und wir haben es mit unserem System "Dieses Verzeichnis automatisch spinnen und neue Inhalte auf den Handheld hochladen" kombiniert. Alles, was wir tun müssten, ist die Zwischensequenz / Ebene / was auch immer zu verlassen und wieder hereinzukommen, und nicht nur die neuen Daten (Sprites, Ebenenlayout usw.) würden geladen, sondern auch jeglicher neue Code in die Überlagerung. Leider wird dies bei neueren Handhelds aufgrund des Kopierschutzes und der Anti-Hacking-Mechanismen, die Code speziell behandeln, immer schwieriger. Wir machen das immer noch für Lua-Skripte.

Last but not least: Sie können (und ich muss, unter verschiedenen sehr kleinen spezifischen Umständen) ein bisschen Entenpunsch machen, indem Sie Anweisungs-Opcodes direkt patchen. Dies funktioniert jedoch am besten, wenn Sie sich auf einer festen Plattform und einem Compiler befinden. Da diese Plattform kaum zu warten ist, sehr fehleranfällig ist und nur eine begrenzte Anzahl von Funktionen zur Verfügung steht, verwende ich sie meistens nur, um den Code beim Debuggen neu zu routen. Es bringt Ihnen jedoch verdammt viel über Ihre Befehlssatzarchitektur bei.

Leander
quelle
6

Sie können zur Laufzeit so etwas wie Hot-Swap-Module ausführen, indem Sie die Module als Dynamic Link Libraries (oder Shared Libraries unter UNIX) implementieren und mit dlopen () und dlsym () Funktionen dynamisch aus der Bibliothek laden.

Für Windows lauten die Entsprechungen LoadLibrary und GetProcAddress.

Dies ist eine C - Methode, und hat einige Tücken , indem sie es in C ++ verwenden, können Sie darüber lesen Sie hier .

Matias Valdenegro
quelle
Dieser Leitfaden ist der Schlüssel zum
Erstellen von
4

Wenn Sie Visual Studio C ++ verwenden, können Sie Ihren Code tatsächlich unter bestimmten Umständen anhalten und neu kompilieren. Visual Studio unterstützt Bearbeiten und Fortfahren . Fügen Sie im Debugger eine Verknüpfung zu Ihrem Spiel hinzu, halten Sie es an einem Haltepunkt an und ändern Sie den Code nach dem Haltepunkt. Wenn Sie speichern und dann fortfahren möchten, versucht Visual Studio, den Code erneut zu kompilieren, ihn erneut in die aktuelle ausführbare Datei einzufügen und fortzufahren. Wenn alles gut geht, wird die Änderung, die Sie vorgenommen haben, live auf das laufende Spiel angewendet, ohne dass ein vollständiger Kompilierungs-Build-Testzyklus durchgeführt werden muss. Das Folgende verhindert jedoch, dass dies funktioniert:

  1. Das Ändern der Rückruffunktionen funktioniert nicht ordnungsgemäß. E & C fügt einen neuen Codeabschnitt hinzu und ändert die Aufruftabelle so, dass sie auf den neuen Block zeigt. Für Rückrufe und alle Funktionen, die Funktionszeiger verwenden, werden weiterhin die alten, nicht geänderten Aufrufe ausgeführt. Um dies zu beheben, können Sie eine Wrapper-Rückruffunktion verwenden, die eine statische Funktion aufruft
  2. Das Ändern von Header-Dateien funktioniert so gut wie nie. Dies dient zum Ändern der tatsächlichen Funktionsaufrufe.
  3. Verschiedene Sprachkonstrukte werden es auf mysteriöse Weise zum Scheitern bringen. Nach meiner persönlichen Erfahrung wird dies häufig durch die Voraberklärung von Dingen wie Enums erreicht.

Ich habe Edit and Continue verwendet, um die Geschwindigkeit von Dingen wie der UI-Iteration dramatisch zu verbessern. Angenommen, Sie haben eine funktionierende Benutzeroberfläche, die vollständig im Code integriert ist, mit der Ausnahme, dass Sie versehentlich die Zeichenreihenfolge von zwei Feldern vertauscht haben, sodass Sie nichts sehen können. Indem Sie 2 Codezeilen live ändern, können Sie sich einen 20-minütigen Kompilierungs- / Build- / Testzyklus ersparen, um einen trivialen UI-Fix zu überprüfen.

Dies ist KEINE vollständige Lösung für eine Produktionsumgebung, und ich habe die beste Lösung gefunden, um so viel wie möglich von Ihrer Logik in Datendateien zu verschieben und diese Datendateien dann neu zu laden.

Ben Zeigler
quelle
Welcher Kompilierungszyklus dauert 20 Minuten?!? Ich würde mich lieber selbst erschießen
JqueryToAddNumbers
3

Wie andere gesagt haben, ist es ein schweres Problem, C ++ dynamisch zu verknüpfen. Aber es ist ein gelöstes Problem - Sie haben vielleicht von COM oder einem der Marketingnamen gehört, die im Laufe der Jahre darauf angewendet wurden: ActiveX.

COM hat aus Entwicklersicht einen schlechten Ruf, da es sehr aufwändig sein kann, C ++ - Komponenten zu implementieren, die ihre Funktionalität offenlegen (obwohl dies mit ATL - der ActiveX-Vorlagenbibliothek - einfacher gemacht wird). Aus Verbrauchersicht hat es einen schlechten Ruf, weil Anwendungen, die es verwenden, um beispielsweise eine Excel-Tabelle in ein Word-Dokument oder ein Visio-Diagramm in eine Excel-Tabelle einzubetten, früher häufig abstürzten. Und das führt zu den gleichen Problemen - trotz aller Anleitungen, die Microsoft anbietet, war / ist es schwierig, COM / ActiveX / OLE richtig zu machen.

Ich werde betonen, dass die Technologie von COM an sich nicht schlecht ist. Erstens verwendet DirectX COM-Schnittstellen, um seine Funktionalität zu offenbaren, und das funktioniert gut genug, ebenso wie eine Vielzahl von Anwendungen, die Internet Explorer mithilfe seines ActiveX-Steuerelements einbetten. Zweitens ist es eine der einfachsten Möglichkeiten, C ++ - Code dynamisch zu verknüpfen - eine COM-Schnittstelle ist im Wesentlichen nur eine reine virtuelle Klasse. Obwohl es eine IDL wie CORBA hat, müssen Sie diese nicht verwenden, insbesondere wenn die von Ihnen definierten Schnittstellen nur in Ihrem Projekt verwendet werden.

Wenn Sie nicht für Windows schreiben, denken Sie nicht, dass COM keine Überlegung wert ist. Mozilla hat es in seiner Codebasis (die im Firefox-Browser verwendet wird) erneut implementiert, weil sie eine Möglichkeit brauchten, ihren C ++ - Code zu komponieren.

U62
quelle
2

Es gibt eine Implementierung von Runtime-kompilierten C ++ für Gameplay Code hier . Ich weiß auch, dass es mindestens eine proprietäre Spiel-Engine gibt, die dasselbe tut. Es ist schwierig, aber machbar.

Laurent Couvidou
quelle