Gamehacking gier opartych na Unity (część 1)

Wstęp

Jeśli grasz w gry wideo, zapewne wielokrotnie zdarzył ci się moment, w którym miałeś trudności z przejściem etapu lub z innego powodu marzyłeś o zmodyfikowaniu mechaniki w ogrywanym tytule. Chociaż na pierwszy rzut oka może się to wydać niemożliwe lub przytłaczające, w przypadku silnika Unity1 niektóre modyfikacje można przeprowadzić w prosty sposób.

Narzędzia

Na potrzeby artykułu posłużymy się następującymi narzędziami (na środowisku z zainstalowanym Windows 11):

  • dnSpy do analizy kodu zawartego w Assembly-CSharp.dll (narzędzie nie jest już rozwijane, jednak dalej spełnia dobrze swoją funkcję)
  • Visual Studio 2022 z toolsetem C# posłuży nam za IDE do napisania i skompilowania kodu wprowadzającego poprawki
  • SharpMonoInjector umożliwia wstrzyknięcie naszego kodu do uruchomionego procesu gry

Warto zaznaczyć, że są to tylko jedne z programów spełniających te funkcje. Nic nie stoi na przeszkodzie, aby użyć alternatyw jak dotPeek i Rider.

Artykuł zakłada, że czytelnik posiada podstawową wiedzę na temat C#.

Kod gry

Jeśli nie mamy pewności czy gra jest oparta na Unity, warto sprawdzić czy w jej katalogu znajduje się plik Managed/Assembly-CSharp.dll2 3. W przypadku zastosowania IL2CPP znajdziesz plik GameAssembly.dll.

Po załadowaniu tego pierwszego w dnSpy naszym oczom ukazuje się widok podobny do poniższego

Interfejs dnSpy nie jest skomplikowany i przypomina Visual Studio. Lista po lewej stronie przedstawia załadowane pliki, które możemy eksplorować. Po kliknięciu na wpis, ten rozwija się, dając nam wgląd na różne kategorie dotyczące właściwości pliku i zawartych w nim symboli. - to globalna przestrzeń nazw, która nas najbardziej interesuje. Przeglądając klasy możemy dostrzec kilka mogących nas zainteresować, jak na przykład InstaDeath i HeroController. Kliknięcie na nazwę z listy uruchomi listing jej kodu. Nie będzie on perfekcyjnym odzwierciedleniem tego co napisał programista, ponieważ jest to rekonstrukcja na podstawie kodu IL, ale nie stanowi to problemu.

Budowa Unity

Zanim zabierzemy się za modyfikowanie zachowania gry, warto zrozumieć jak ona działa. Artykuł nie ma na celu dogłębnego wyjaśnienia środowiska Unity (zainteresowanych odsyłam do dowolnego kursu gamedevu), ale poniżej zostaną omówione kluczowe aspekty.

  • Scena jest fundamentem, do którego są przypisane inne elementy. Może reprezentować na przykład menu lub mapę.
  • GameObject jest niczym innym jak dowolnym obiektem, na przykład postacią gracza lub przeciwnikiem.
  • Component opisuje zachowanie GameObjectu, do którego jest przypisany. Komponenty mogą odpowiadać za akcje jak poruszanie się lub atak. Z tego powodu to głównie nimi dziś będziemy się zajmować.

Działanie komponentu jest oparte o zdarzenia obsługiwane przez różne metody (jak OnEnable lub Start) w obrębie klasy stanowiącej jego implementację. Oprócz kodu znajdują się tam również zmienne przechowujące jego stan, co może się nam przydać do zmiany wartości jak punkty zdrowia.

Dalsza analiza kodu

Na podstawie nazwy klasy HeroController można stwierdzić, że odpowiada ona za kontrolę naszej postaci. Szybkie spojrzenie na składowe zdaje się potwierdzać te przypuszczenia.

Widzimy między innymi pole typu bool takeNoDamage, które prawdopodobnie może służyć do wyłączenia przyjmowania obrażeń. Na potrzeby tekstu nie będę się zagłębiał w kod, by to zweryfikować i sprawdzę to empirycznie.

Wśród metod widnieje jedna o nazwie CanInfiniteAirJump. Rzut oka na jej implementację:

private bool CanInfiniteAirJump()  
{    return this.playerData.infiniteAirJump && this.hero_state != ActorStates.hard_landing && !this.cState.onGround;  
}

pozwala zrozumieć, że do odblokowania możliwości wykonywania nieskończonych skoków może doprowadzić zmiana wartości zmiennej playerData.infiniteAirJump na true. Na szczęście playerData jest publicznym polem i uzyskanie do niego dostępu z naszego kodu nie będzie stanowiło problemu.

Na ten moment nasze działania będą składać się tylko z tych dwóch modyfikacji. Czas zrobić użytek z pozyskanej przed chwilą wiedzy.

Konfiguracja projektu w Visual Studio

Jako typ projektu wybieramy C# Class Library. Po przejściu dalej określamy miejsce, gdzie na dysku zostanie zapisany projekt i jego nazwę. Na następnym etapie musimy wybrać wersję .NET. Możemy ją odczytać w dnSpy.

Obecność zależności netstandard świadczy o tym, że został użyty .NET Standard. Po najechaniu na wpis kursorem naszym oczom ukazuje się wersja 2.0. Z uwagi na to, że wersje 2.x są kompatybilne wstecznie wybieram najnowszą dostępną czyli 2.1.

Patch działa na zasadzie bycia kolejnym skryptem, jak każdy inny w grze. Takie podejście daje nam dostęp do wszystkich funkcji używanych przez silnik. Jednak z tego powodu trzeba najpierw dodać biblioteki z tymi funkcjami jako zależności projektu. Na szczęście jest to proste - wystarczy kliknąć PPM na Dependencies w eksploratorze rozwiązania w oknie Visual Studio.
W oknie, które się wyświetli przycisk Browse uruchamia eksplorator plików. Przechodzimy w nim do katalogu Managed gry i dodajemy wszystkie pliki dll, które się tam znajdują. Zatwierdzamy poprzez OK i możemy zacząć pisać kod.

Pisanie patcha

Nie wystarczy samo dodanie plików gry do projektu. Trzeba również zaimportować przestrzeń nazw, w której znajdują się używane przez nas funkcje.

using UnityEngine; // podstawowe klasy silnika

Tak prezentuje się komponent implementujący funkcjonalność patcha:

public class Patch : MonoBehaviour
{
    private bool noDamage = false;
    private bool infiniteJump = false;

    public void OnGUI()
    {
        if (GameManager.instance.gameState != GlobalEnums.GameState.PLAYING) 
        { 
            noDamage = UnityEngine.GUI.Toggle(new Rect(10, 10, 100, 20), noDamage, new GUIContent(text: "Brak obrażeń"));
            infiniteJump = UnityEngine.GUI.Toggle(new Rect(10, 25, 150, 20), infiniteJump, new GUIContent(text: "Nieskończony skok"));
        }
    }

    public void Update() 
    {

        var controller = UnityEngine.Object.FindObjectOfType<HeroController>();

        controller.takeNoDamage = noDamage;

        controller.playerData.infiniteAirJump = infiniteJump;
    }
}

Zmienne noDamage i infiniteJump przechowują informacje czy dana funkcja jest włączona.

Do ich włączania lub wyłączania posłuży GUI tworzone w metodzie OnGUI. UnityEngine.GUI to API pozwalające zaimplementować w prosty sposób podstawowy interfejs użytkownika. Sprawdzenie stanu gry if (GameManager.instance.gameState != GlobalEnums.GameState.PLAYING) wyświetla elementy interfejsu tylko wtedy, gdy nie prowadzimy rozgrywki (na przykład jesteśmy w menu głównym lub w ustawieniach).

Kod Update, podobnie jak OnGUI, jest wykonywany co klatkę. To tam powinny znaleźć się wszystkie oddziaływania na obiekty. Za pomocą FindObjectOfType odnajdujemy kontroler postaci głównego bohatera i ustawiamy odpowiednio wartości znalezionych uprzednio zmiennych. Uwaga: wygląda na to, że zapisując postęp, gra przechowuje również stan nadpisywanych przez nas zmiennych. Warto o tym pamiętać przy napotkaniu dziwnych zachowań bez załadowanego patcha.

Aby kod został wykonany przez Unity, trzeba do czegoś przypisać napisany przez nas komponent. W tym celu tworzymy klasę Main, która po załadowaniu biblioteki do gry tworzy nowy GameObject i używa go do uruchomienia powyższego kodu. Mówimy również silnikowi, by nie niszczył tego obiektu przy zmianie sceny (jak na przykład przejściu z rozgrywki do głównego menu).

public class Class1
{
    private static GameObject InjectedObject;
    public static void Main()
    {
        InjectedObject = new GameObject();
        InjectedObject.AddComponent<Patch>();

        UnityEngine.Object.DontDestroyOnLoad(InjectedObject);
    }
}

Unity samo nie uruchomi kodu w funkcji Main, za to odpowiada MonoInjector po podaniu odpowiednich parametrów.

Gdy wszystko jest gotowe, naciśnięcie kombinacji Ctrl+Shift+B skompiluje projekt. Wynikowy plik DLL (o nazwie takiej jak projekt) znajduje się w ścieżce bin\Debug\netstandard2.1 (lub bin\Release\netstandard2.1) w katalogu z projektem.

Wprowadzenie patcha do gry

Poniższe wywołanie wersji Console SharpMonoInjectora ładuje bibliotekę do procesu gry smi.exe inject -p ID_PROCESU_HEX -a "C:\Users\WaletLab\Desktop\WaletLab\ClassLibrary1\bin\Debug\netstandard2.1\ClassLibrary1.dll" -n ClassLibrary1 -c Class1 -m Main

Identyfikator procesu gry można odczytać w Menadżerze Zadań i przeliczyć go na system szesnastkowy.

  • -a odpowiada za ścieżkę do naszego pliku DLL
  • -n służy do przekazania nazwy przestrzeni nazw (zdefiniowanej za pomocą namespace), w której znajduje się klasa odpowiadająca za inicjalizację.
  • -c pozwala nam sprecyzować klasę, w której jest metoda inicjalizująca nasze patche
  • -m to nazwa wyżej wymienionej metody

Niektóre wartości mogą się różnić w zależności od wybranej nazwy projektu.

Efekty

Jeśli wszystko się powiodło, będziesz mógł zaobserwować oskryptowane w powyższym kodzie zachowania w grze. Pamiętaj, by nie psuć zabawy innym graczom przez uzyskiwanie w ten sposób nieuczciwej przewagi w grach multiplayer.

Cały kod jest dostępny pod tym linkiem

Błędy

Masz uwagi odnośnie artykułu? Napotkałeś problem? Zapraszamy na nasz serwer Discord.


  1. W tej części zajmiemy się inżynierią wsteczną gry, w której wydaniu nie zostało wykorzystane IL2CPP. Działania w przypadku zastosowania tego narzędzia będą tematem kontynuacji. ↩︎

  2. Plik z firstpass w nazwie też może zawierać przydatne dla nas skrypty. Proces kompilacji skryptów w Unity ↩︎

  3. Katalog Managed w tym przypadku znajduje się w hollow_knight_Data ↩︎