Jak wdrożyć wymienialne podczas pracy moduły C ++?

39

Szybkie czasy iteracji są kluczem do tworzenia gier, o wiele bardziej niż wymyślna grafika i silniki z mnóstwem funkcji moim zdaniem. Nic dziwnego, że wielu małych programistów wybiera języki skryptowe.

Sposób Unity 3D polegający na możliwości wstrzymania gry i zmodyfikowania zasobów ORAZ kodu, a następnie kontynuowania i natychmiastowego wprowadzenia zmian, jest absolutnie doskonały. Moje pytanie brzmi: czy ktoś zaimplementował podobny system w silnikach gier C ++?

Słyszałem, że robią to niektóre naprawdę supernowoczesne silniki, ale jestem bardziej zainteresowany dowiedzieć się, czy jest sposób na to w domowej maszynie lub grze.

Oczywiście wystąpiłyby kompromisy i nie mogę sobie wyobrazić, że możesz po prostu ponownie skompilować kod, gdy gra jest wstrzymana, aby została ponownie załadowana i działała w każdych okolicznościach i na każdej platformie.

Ale być może jest to możliwe w przypadku programowania AI i prostych modułów logicznych poziomu. To nie tak, że chcę to zrobić w jakimkolwiek projekcie w perspektywie krótko- lub długoterminowej, ale byłem ciekawy.

Zły Engel
źródło

Odpowiedzi:

26

Z pewnością jest to możliwe, chociaż nie z typowym kodem C ++; musisz utworzyć bibliotekę w stylu C, którą możesz dynamicznie łączyć i ponownie ładować w czasie wykonywania. Aby było to możliwe, biblioteka musi zawierać cały stan w nieprzezroczystym wskaźniku, który może być dostarczony do biblioteki po ponownym załadowaniu.

Timothy Farrar omawia swoje podejście:

„W celu programowania kod jest kompilowany jako biblioteka, mały program ładujący tworzy kopię biblioteki i ładuje kopię biblioteki, aby uruchomić program. Program przydziela wszystkie dane podczas uruchamiania i używa jednego wskaźnika do odniesienia do danych. Nie używam nic globalnego oprócz danych tylko do odczytu. Podczas działania programu oryginalna biblioteka może zostać ponownie skompilowana. Następnie jednym naciśnięciem klawisza program wraca do modułu ładującego i zwraca pojedynczy wskaźnik danych. Moduł ładujący tworzy kopię nowej biblioteki, ładuje kopię i przekazuje wskaźnik danych do nowego kodu. Następnie silnik kontynuuje od miejsca, w którym został przerwany. ” ( oryginalne źródło , teraz dostępne w archiwum internetowym )

Jason Kozak
źródło
Wolę to od odpowiedzi Blair, ponieważ tak naprawdę odpowiadasz na pytanie.
Jonathan Dickinson
15

Wymiana na gorąco jest bardzo, bardzo trudnym problemem do rozwiązania za pomocą kodu binarnego. Nawet jeśli kod zostanie umieszczony w osobnych bibliotekach linków dynamicznych, kod musi zapewnić, że nie będzie bezpośrednich odwołań do funkcji w pamięci (ponieważ adres funkcji może ulec zmianie przy rekompilacji). Zasadniczo oznacza to, że nie używa się funkcji wirtualnych, a wszystko, co korzysta ze wskaźników funkcji, robi to za pomocą pewnego rodzaju tabeli wysyłki.

Owłosione, krępujące rzeczy. Lepiej jest użyć języka skryptowego dla kodu, który wymaga szybkiej iteracji.

W pracy nasza obecna baza kodu to połączenie C ++ i Lua. Lua też jest niemałą częścią naszego projektu - to prawie podział 50/50. Wdrożyliśmy przeładowywanie Lua w locie, abyś mógł zmienić linię kodu, przeładować i kontynuować. W rzeczywistości możesz to zrobić, aby naprawić błędy awaryjne występujące w kodzie Lua, bez ponownego uruchamiania gry!

Blair Holloway
źródło
3
Inną ważną kwestią jest to, że nawet jeśli nie zamierzasz utrzymywać systemu w skrypcie, jeśli spodziewasz się dużo iteracji na wczesnym etapie, nadal może być całkowicie uzasadnione rozpoczęcie w Lua, a następnie przejście do C ++, gdy potrzebujesz dodatkowa wydajność i tempo iteracji uległo spowolnieniu. Oczywistą wadą jest to, że w końcu przenosisz kod, ale często logika jest trudna - kod prawie się pisze, gdy się zorientujesz.
Logan Kincaid,
15

(Możesz chcieć wiedzieć o terminach „łatanie małp” lub „wykaczanie kaczek”, jeśli tylko dla humorystycznego obrazu mentalnego.)

Poza tym: jeśli Twoim celem jest skrócenie czasu iteracji dla zmian „zachowania”, wypróbuj kilka metod, które zapewnią ci najwięcej możliwości, i połącz je ładnie, aby umożliwić więcej tego w przyszłości.

(To wyjdzie na trochę styczne, ale obiecuję, że wróci!)

  • Zacznij od danych i zacznij od małego: przeładuj na granicach („poziomach” lub podobnych), a następnie przejdź do korzystania z funkcji systemu operacyjnego, aby otrzymywać powiadomienia o zmianie plików lub po prostu regularnie sondować .
  • (W celu uzyskania punktów bonusowych i krótszych czasów ładowania (ponownie, zmniejszając czas iteracji) zajrzyj do wypalania danych .)
  • Skrypty są danymi i umożliwiają iterację zachowania. Jeśli używasz języka skryptowego, masz teraz powiadomienia / możliwość ponownego załadowania tych skryptów, zinterpretowanych lub skompilowanych. Możesz także podłączyć tłumacza do konsoli w grze, gniazda sieciowego lub podobnego elementu, aby zwiększyć elastyczność środowiska wykonawczego.
  • Kod może być także danymi : Twój kompilator może obsługiwać nakładki , biblioteki współdzielone, biblioteki DLL i tym podobne. Możesz teraz wybrać „bezpieczny” czas na rozładowanie i ponowne załadowanie nakładki lub biblioteki DLL, zarówno ręcznej, jak i automatycznej. Pozostałe odpowiedzi są tutaj szczegółowo opisane. Należy pamiętać, że niektóre warianty tego mogą powodować problemy z weryfikacją podpisu kryptograficznego, bitem NX (brak wykonania) lub podobnymi mechanizmami bezpieczeństwa.
  • Rozważ głęboki, wersjonowany system zapisu / ładowania . Jeśli możesz bezpiecznie zapisać i przywrócić swój stan nawet w obliczu zmian kodu, możesz zamknąć grę i uruchomić ją ponownie z nową logiką dokładnie w tym samym punkcie. Łatwiej powiedzieć niż zrobić, ale jest to wykonalne i jest znacznie łatwiejsze i bardziej przenośne niż szturchanie pamięci w celu zmiany instrukcji.
  • W zależności od struktury i determinizmu gry możesz nagrywać i odtwarzać . Jeśli nagranie jest tuż nad „poleceniami gry” (na przykład grą karcianą), możesz zmienić cały kod renderowania i odtworzyć nagranie, aby zobaczyć zmiany. W przypadku niektórych gier jest to tak „łatwe”, jak rejestracja niektórych parametrów początkowych (np. Losowego początku), a następnie działań użytkownika. Dla niektórych jest to o wiele bardziej skomplikowane.
  • Staraj się skracać czas kompilacji . W połączeniu z wyżej wymienionymi systemami zapisu / ładowania lub nagrywania / odtwarzania, a nawet z nakładkami lub bibliotekami DLL, może to zmniejszyć twój zwrot bardziej niż jakakolwiek inna rzecz.

Wiele z tych punktów jest korzystnych, nawet jeśli nie uda ci się przeładować ani danych, ani kodu.

Wspierające anegdoty:

Na dużym PC RTS (~ 120-osobowy zespół, głównie C ++), istniał niesamowicie głęboki system oszczędzania stanu, który był wykorzystywany do co najmniej trzech celów:

  • „Płytkie” zapisanie zostało wprowadzone nie na dysk, ale do silnika CRC, aby zapewnić, że gry wieloosobowe pozostaną w symulacji blokowania jeden CRC co 10-30 klatek; zapewniło to, że nikt nie oszukuje i kilka sekund później wyłapał błędy związane z desynchronizacją
  • Jeśli i kiedy wystąpi błąd desynchronizacji dla wielu graczy, w każdej klatce wykonywany jest wyjątkowo głęboki zapis i ponownie przekazywany do silnika CRC, ale tym razem silnik CRC wygeneruje wiele CRC, każda dla mniejszych partii bajtów. W ten sposób może dokładnie powiedzieć, która część stanu zaczęła się rozchodzić w ostatniej ramce. Zauważyliśmy nieprzyjemną różnicę między „domyślnym trybem zmiennoprzecinkowym” między procesorami AMD i Intel.
  • Normalny zapis głębokości może nie zapisać np. Dokładnej klatki animacji, w którą grała twoja jednostka, ale uzyskałby pozycję, zdrowie itp. Wszystkich twoich jednostek, umożliwiając zapisywanie i wznawianie w dowolnym momencie podczas gry.

Od tego czasu używałem deterministycznego nagrywania / odtwarzania w grze karcianej C ++ i Lua dla DS. Połączyliśmy się z interfejsem API zaprojektowanym dla AI (po stronie C ++) i zarejestrowaliśmy wszystkie działania użytkownika i AI. Wykorzystaliśmy tę funkcję w grze (aby zapewnić powtórkę dla odtwarzacza), ale także w celu zdiagnozowania problemów: gdy wystąpił awaria lub dziwne zachowanie, wystarczyło pobrać plik zapisu i odtworzyć go w kompilacji debugowania.

Od tego czasu używałem nakładek więcej niż kilka razy i połączyliśmy to z naszym „automatycznie przeglądającym ten katalog i przesyłam nowe treści do systemu podręcznego”. Wszystko, co musielibyśmy zrobić, to opuścić scenę przerywnikową / poziom / cokolwiek i wrócić i nie tylko nowe dane (duszki, układ poziomów itp.) Załadują się, ale także nowy kod w nakładce. Niestety, staje się to coraz trudniejsze w przypadku nowszych urządzeń przenośnych ze względu na ochronę przed kopiowaniem i mechanizmy zapobiegające hakowaniu, które specjalnie traktują kod. Nadal jednak robimy to dla skryptów lua.

Last but not least: możesz (i ja, w różnych bardzo małych szczególnych okolicznościach) zrobić trochę dziurkowania, bezpośrednio łatając kody instrukcji. Działa to najlepiej, jeśli jesteś na stałej platformie i kompilatorze, a ponieważ jest prawie nie do utrzymania, bardzo podatny na błędy i ograniczony w tym, co możesz szybko osiągnąć, używam go głównie do zmiany trasy kodu podczas debugowania. To nie nauczy Cię cholernie dużo o swojej zestaw instrukcji architektury w pośpiechu, choć.

leander
źródło
6

Możesz zrobić coś takiego jak wymiana modułów w czasie wykonywania, implementując moduły jako biblioteki linków dynamicznych (lub biblioteki współdzielone w systemie UNIX) i używając dlopen () i dlsym () do dynamicznego ładowania funkcji z biblioteki.

W systemie Windows odpowiednikami są LoadLibrary i GetProcAddress.

Jest to metoda języka C i wiąże się z pewnymi pułapkami przy użyciu jej w języku C ++, o czym możesz przeczytać tutaj .

Matias Valdenegro
źródło
ten przewodnik jest kluczem do tworzenia bibliotek z możliwością wymiany podczas pracy, dziękuję
JqueryToAddNumbers
4

Jeśli używasz programu Visual Studio C ++, w rzeczywistości możesz w pewnych okolicznościach wstrzymać i ponownie skompilować kod. Visual Studio obsługuje edycję i kontynuację . Dołącz do gry w debuggerze, zatrzymaj ją w punkcie przerwania, a następnie zmodyfikuj kod po punkcie przerwania. Jeśli chcesz zapisać, a następnie kontynuować, Visual Studio podejmie próbę ponownej kompilacji kodu, włóż go ponownie do działającego pliku wykonywalnego i kontynuuj. Jeśli wszystko pójdzie dobrze, wprowadzona zmiana zostanie zastosowana do uruchomionej gry bez konieczności wykonywania całego cyklu kompilacji-kompilacji-testu. Jednak następujące przestaną działać:

  1. Zmiana funkcji oddzwaniania nie będzie działać poprawnie. E&C działa poprzez dodanie nowego fragmentu kodu i zmianę tabeli wywołań, aby wskazywały na nowy blok. W przypadku wywołań zwrotnych i innych elementów korzystających ze wskaźników funkcji nadal będzie wykonywać stare, niezmodyfikowane wywołania. Aby to naprawić, możesz mieć funkcję oddzwaniania opakowania, która wywołuje funkcję statyczną
  2. Modyfikowanie plików nagłówkowych prawie nigdy nie zadziała. Służy to do modyfikowania rzeczywistych wywołań funkcji.
  3. Różne konstrukcje językowe powodują, że w tajemniczy sposób zawodzi. Z mojego osobistego doświadczenia często deklarują takie rzeczy jak wyliczenia.

Użyłem opcji Edytuj i kontynuuj, aby radykalnie poprawić szybkość takich czynności, jak iteracja interfejsu użytkownika. Załóżmy, że masz działający interfejs użytkownika zbudowany całkowicie w kodzie, z wyjątkiem tego, że przypadkowo zamieniłeś kolejność rysowania dwóch pól, więc nic nie widzisz. Zmieniając 2 wiersze kodu na żywo, możesz zaoszczędzić sobie 20-minutowy cykl kompilacji / kompilacji / testowania, aby sprawdzić trywialną poprawkę interfejsu użytkownika.

To NIE jest pełne rozwiązanie dla środowiska produkcyjnego i znalazłem najlepsze rozwiązanie, aby przenieść jak najwięcej logiki do plików danych, a następnie sprawić, by te pliki danych mogły zostać ponownie załadowane.

Ben Zeigler
źródło
który cykl kompilacji zajmuje 20 minut?!? wolałbym się zastrzelić
JqueryToAddNumbers
3

Jak powiedzieli inni, jest to trudny problem, dynamicznie łącząc C ++. Ale jest to rozwiązany problem - mogłeś słyszeć o COM lub o jednej z nazw marketingowych, które były stosowane do niego przez lata: ActiveX.

COM ma nieco złą nazwę z punktu widzenia programisty, ponieważ implementacja komponentów C ++, które ujawniają ich funkcjonalność przy użyciu tego narzędzia, może być bardzo trudna (chociaż jest to łatwiejsze dzięki ATL - bibliotece szablonów ActiveX). Z punktu widzenia konsumenta ma złą nazwę, ponieważ aplikacje, które go używają, na przykład do osadzania arkusza kalkulacyjnego Excel w dokumencie Word lub diagramu Visio w arkuszu kalkulacyjnym Excel, miały tendencję do zawieszania się dość dawno temu. I to sprowadza się do tych samych problemów - nawet przy wszystkich wskazówkach, które oferuje Microsoft, COM / ActiveX / OLE było / trudno jest dobrze zrozumieć.

Podkreślę, że technologia COM sama w sobie nie jest zła. Po pierwsze, DirectX wykorzystuje interfejsy COM, aby ujawnić swoją funkcjonalność, która działa wystarczająco dobrze, podobnie jak wiele aplikacji, które osadzają Internet Explorera za pomocą kontrolki ActiveX. Po drugie, jest to jeden z najprostszych sposobów dynamicznego łączenia kodu C ++ - interfejs COM jest w gruncie rzeczy czystą klasą wirtualną. Chociaż ma IDL, taki jak CORBA, nie musisz go używać, zwłaszcza jeśli zdefiniowane przez Ciebie interfejsy są używane tylko w twoim projekcie.

Jeśli nie piszesz dla systemu Windows, nie myśl, że COM nie jest warte rozważenia. Mozilla ponownie zaimplementowała go w swojej bazie kodu (używanej w przeglądarce Firefox), ponieważ potrzebowała sposobu na skomponowanie kodu C ++.

U62
źródło
2

Jest to realizacja runtime-kompilowane kodu C ++ dla rozgrywki tutaj . Wiem też, że istnieje co najmniej jeden zastrzeżony silnik gry, który robi to samo. To trudne, ale wykonalne.

Laurent Couvidou
źródło