Gothic DevOps, czyli jak pracują programiści Historii Neka

,

Od samego początku pracy nad techniczną stroną Historii Neka testowaliśmy różne podejścia do tematu pracy programistów z lepszym lub gorszym skutkiem, jednak koniec końców po ponad roku w produkcji wyklarowały nam się procesy i narzędzia, które usprawniają pracę. Jeżeli interesuje cię temat modowania Gothica lub po prostu lubisz patrzeć „za kulisy”, to znakomicie, bo dzisiaj na obiad będzie ugotowane mięsko.

Organizacja pracy

Zanim przejdziemy do technicznych aspektów, to tytułem wstępu o organizacji pracy, bo przy realizacji dużych projektów jest to jeszcze ważniejsze niż sama praca. 

Śledzenie zadań 

Opieramy zarządzanie pracą o proste tablice Kanbanowe, na których rejestrujemy listę rzeczy do zrobienia i śledzimy postęp ich wykonania. Miał być Scrum, ale okazało się, że nie ma owocowych czwartków i nie stać nas na zatrudnienie Scrum Mastera, więc nie możemy sprintować (#pdk). Do obsługi tych tablic wykorzystujemy ClickUp, bo jako jedyny był w stanie dostarczyć sensowny zestaw funkcji dla 14 osób w rozsądnej cenie (czyt. za darmo).

Repozytorium kodu

Dla kodu modyfikacji i projektów satelitarnych wykorzystujemy Gita jako system kontroli wersji i trzymamy repozytoria na GitHubie. Jeżeli nie wiesz czym jest Git, to jest to takie narzędzie, które pozwala wielu ludziom pracować równolegle nad projektem przez dzielenie go na gałęzie. Na każdej gałęzi można pracować niezależnie, a następnie łączyć je ze sobą, zachowując zmiany z obu. Zazwyczaj połącznie gałęzi przebiega automatycznie, ale w przypadku modyfikacji tego samego miejsca przez 2 osoby może wystąpić konflikt, który podczas łączenia trzeba rozwiązać ręcznie, ustalając co finalnie trafi do kodu.

Stosujemy Trunk-Based Development, więc każdy programista rozpoczynający pracę tworzy swoją gałąź na podstawie głównej gałęzi main. Gdy wykona zadanie, to tworzy Pull Request, czyli prośbę o połączenie swojej gałęzi z main, a inni programiści weryfikują zmiany i w przypadku braku uwag zmiany są przenoszone na main, z którego pozostali mogą zaktualizować swoje gałęzie. Dzięki temu pracujemy niezależnie, nie wchodząc sobie w drogę, a częste dodawanie zmian do maina powoduje, że każdy ma świeżą wersję kodu.

Baza wiedzy

Do dzielenia się wiedzą korzystamy z wewnętrznej Wiki opartej o Outline, na której przechowujemy dokumentację technicznych aspektów projektu, a gdy potrzebujemy jakiejś tabelki, to stawiamy na Google Sheets. Wiki jest używana przez cały zespół i zawiera też bazę wiedzy o fabule oraz postaciach w modyfikacji, żeby każdy mógł wczuć się w klimat tego co tworzy.

Gothic Mod Build Tool (GMBT)

W samym sercu repozytorium moda leży Gothic Mod Build Tool (GMBT) stworzony przez Szmyka dla Kronik Myrtany i ulepszony przez nas w zakresie raportowania błędów na potrzeby naszego procesu. GMBT używamy do organizacji kodu poprzez rozdzielenie MDK (Mod Development Kit), zewnętrznych zależności oraz naszego własnego kodu na różne foldery, które trzymamy poza katalogiem roboczym Gothica. GMBT podczas buildu łączy poszczególne katalogi z kodem, a następnie kopiuje je do katalogu roboczego, z którego już korzysta Gothic. Zachowujemy dzięki temu porządek w repozytorium i możliwość stworzenia własnej struktury katalogów.

W czasie wykonywania komend GMBT można zdefiniować hooki, czyli własne skrypty odpalane w konkretnym momencie procesu. My wykorzystujemy:

  • tools\clean-compiled.bat @test.pre.assetsMerge, @compile.pre.assetsMerge
    Skrypt usuwa skompilowane assety z katalogu roboczego tuż przed tym, gdy GMBT zacznie tam kopiować nasze pliki z repozytorium. Dzięki temu możemy łatwo podmieniać assety, które kompilują się tylko wtedy, gdy jeszcze nie są skompilowane. 
  • tools\copy-union.bat @common.post.subtitlesUpdate
    Skrypt kopiuje nasze pluginy Uniona z repozytorium do folderu Data i System/Data tuż po tym, jak GMBT wygeneruje pliki OU. Dzięki temu każdy ma wszystkie Uniony, które potrzebuje w takich wersjach, jakie powinny być.
  • tools\localize-strings.bat @common.post.assetsMerge
    Skrypt wykonuje się po przekopiowaniu plików do katalogu roboczego i wykonuje na nich program do tłumaczeń, o którym za moment.

Dzięki temu po odpaleniu komendy gmbt test wszystkie potrzebne pliki trafią tam gdzie powinny, a nam uruchomi się testowa wersja modyfikacji zawierająca wszystko nad czym obecnie pracujemy.

System tłumaczeń

Od początku produkcji mamy zamiar na dzień premiery wydać Historię Neka w języku polskim i angielskim, więc już zawczasu szukaliśmy rozwiązania. Szczególnie zależało nam, żeby tłumaczenia były niezależne od samych skryptów, żeby utrzymywać tylko jedną wersję skryptów. Z tego powodu zdecydowaliśmy się na trzymanie wszystkich tekstów w skryptach w postaci $PLACEHOLDERÓW_W_TAKIM_FORMACIE:

func void DIA_SO_20001_Gomez_R1Q2_D11_AmbushA()
{
    AI_Output(other, self, "DIA_SO_20001_Gomez_R1Q2_D11_AmbushA_15_00"); //$DIA_SO_20001_Gomez_R1Q2_D11_AmbushA_15_00
    AI_Output(self, other, "DIA_SO_20001_Gomez_R1Q2_D11_AmbushA_09_01"); //$DIA_SO_20001_Gomez_R1Q2_D11_AmbushA_09_01


    Info_ClearChoices(DIA_SO_20001_Gomez_R1Q2_D11);
    Info_AddChoice(DIA_SO_20001_Gomez_R1Q2_D11, "$DIA_SO_20001_Gomez_R1Q2_D11_ResistanceB_15_00", DIA_SO_20001_Gomez_R1Q2_D11_ResistanceB);
    Info_AddChoice(DIA_SO_20001_Gomez_R1Q2_D11, "$DIA_SO_20001_Gomez_R1Q2_D11_ResistanceA_15_00", DIA_SO_20001_Gomez_R1Q2_D11_ResistanceA);
};

Jednocześnie w osobnym katalogu tworzymy pliki JSON z taką samą ścieżką, co skrypt, żeby program do tłumaczeń mógł łatwo ustalić, który plik ma modyfikować.

{
    "DIA_SO_20001_Gomez_R1Q2_D11_AmbushA_15_00": "Panie Majster! Słyszałeś, że Nekowcy wyciekaja własne skrypty dialogów na blogu?",
    "DIA_SO_20001_Gomez_R1Q2_D11_AmbushA_09_01": "ZEJDŹ MI Z OCZU! Tylko ktoś taki jak ty uznałby, że ten dialog jest autentyczny.",
    "DIA_SO_20001_Gomez_R1Q2_D11_ResistanceB_15_00": "Aye! Molte volte, komandorze.",
    "DIA_SO_20001_Gomez_R1Q2_D11_ResistanceA_15_00": "A co jeśli?"
}

Podmiana placeholderów następuje po przekopiowaniu plików do katalogu roboczego, gdy GMBT uruchamia nasz hook. Program napisany w Javie i skompilowany do kodu natywnego przez GraalVM jest wielowątkowy i równolegle podmienia placeholdery w kilku plikach naraz przez co proces aplikowania tłumaczeń jest błyskawiczny. 

Minusem tego rozwiązania jest to, że utrudnia nam nieco implementację dialogów, bo musimy teksty trzymać w innym miejscu niż kod, więc czasami można się pogubić, która kwestia jest która. Zaradziliśmy temu, tworząc plugin do Visual Studio Code, który czyta nasze pliki tłumaczeń i wyświetla nam tekst placeholdera nad kodem.

Kod źródłowy modyfikacji z placeholderami tłumaczeń
Kod źródłowy modyfikacji z placeholderami tłumaczeń

BuildRelease.ps1

Za pomocą GMBT jesteśmy w stanie zbudować główną paczkę modyfikacji z assetami i skryptami, ale na nasz projekt składają się jeszcze pluginy Uniona i assety przechowywane poza repozytorium, więc tych paczek stosujemy więcej i musimy je jakoś połączyć w całość. Idealnie do takiej postaci, która jest łatwa w instalacji dla testerów, a później też graczy.

Za zbudowanie takiej paczki dystrybucyjnej odpowiada skrypt BuildRelease.ps1 napisany w PowerShellu. Cały proces opiera się na szablon katalogu z odpowiednią strukturą, do którego kopiujemy niezbędne pliki, czyli paczki VDFS (.mod) zawierające skrypty, assety i Uniony oraz pliki konfiguracyjne. Skrypt generuje plik NH.ini z definicją moda dla GothicStartera i w

Za zbudowanie paczki dystrybucyjnej jest odpowiedzialny skrypt BuildRelease.ps1 napisany w PowerShellu, który tworzy strukturę katalogów na podstawie szablonu, buduje paczki VDFS i kopiuje niezbędne pliki do swojej struktury. W czasie pracy generuje też plik konfiguracyjny NH.ini z automatycznie wpisanymi informacji o wersji, dacie i numerze builda.

Proces budowania opiera się o szablon katalogu, który zawiera kilka statycznych plików i sam skrypt budujący dodatkowe paczki MOD oraz kopiujący wszystko do docelowego ZIPa. Podmieniamy też dane w NH.ini, żeby np. wpisać do niego bieżącą wersję paczki.

# Fragment kodu podmieniający dane w NH.ini

$IniByteArray = [System.IO.File]::ReadAllBytes($IniFile);
$IniContent = $CP1250.GetString($IniByteArray)
$Version = $args[1]
if ($null -eq $Version) {
    $Time = Get-Date -Format "yyyy.MM.dd-HH.mm"
    $Version = "$Time-dev"
}
$IniContent = $IniContent.replace('%VERSION%', $Version)
$IniContent = $IniContent.replace('%BUILD_TIME%', "$(Get-Date -Format "dd.MM.yyyy HH:mm:ss")")
$IniContent = $IniContent.replace('%GIT_COMMIT%', "$(git log -n 1 --no-merges --pretty=oneline)")
$OutputIniFile = [IO.Path]::GetFullPath("./release/output/System/NH.ini")
Write-Host "=== Writing $OutputIniFile" -ForegroundColor Blue
$IniOutputByteArray = $CP1250.GetBytes($IniContent)
[System.IO.File]::WriteAllBytes($OutputIniFile, $IniOutputByteArray)

Po przygotowaniu całego katalogu skrypt pakuje go w archiwum ZIP, które jest gotowe do wysłania odbiorcy. Struktura katalogów jest taka sama jak Gothica II, więc instalacja sprowadza się do wypakowania archiwum na folder z grą i odpalenia modyfikacji przez GothicStarter. Z punktu widzenia programisty, żeby wygenerować taką paczkę, wystarczy wykonać kilka komend:

gmbt test --full
gmbt pack --skipmerge
./BuildRelease.ps1 

Dialogi TTS

Dla pisarzy testujących jak dialogi „grają” w grze znacznie wygodniej jest, jeżeli są czytane, ale na obecnym etapie produkcji nawet się nie myśli o rozpoczęciu nagrań. Nawet gdyby się myślało, to ciężko nagrywać głosy, gdy teksty nie są jeszcze klepnięte. Z tego powodu zdecydowaliśmy się napisać generator Text-to-Speech, który czyta linijki dialogowe z OU.CSL i dla każdej generuje plik dźwiękowy mówiony przez lektora Google Translate. Skrypt po wygenerowaniu plików pakuje je do paczki VDFS, którą można załadować razem z modem.

Kod można znaleźć na tutaj, ale trzeba go dostosować do siebie. Rozwiązanie nie jest idealne, bo ze względu na użycie lektora Google Translate dużo czasu jest poświęcane na komunikacje sieciową i generowanie dialogów dla naszego przypadku zajmuje kilkanaście minut, a będzie jeszcze dłuższe. Szybszym rozwiązaniem byłoby użycie lokalnego silnika TTS (np. Windowsa) albo generowanie TTS na żywo w grze, co realizuje np. zTTSDialogues autorstwa fyryNy.

Jenkins CI/CD

Gdy mieliśmy już proces budowania oparty o skrypty, to zaczął się rodzić pomysł na pełną automatyzację procesu i postawienie serwera CI/CD, który budowałby paczki automatycznie po każdym commicie i mógł je np. dostarczać natychmiastowo dla testerów. Zaczęliśmy tworzyć prototyp z wykorzystaniem Jenkinsa na Windows Server 2022, ale mieliśmy problemy z uruchomieniem pełnej kompilacji projektu przez bałagan w zależnościach i robione przez Jenkinsa buildy były niekompletne. Na jakiś czas sobie temat darowaliśmy i budowaliśmy wszystkie paczki ręcznie razem z wykonywaniem manualnie niezbędnych akcji w grze, żeby się assety do kompilacji doładowały. Dało się tak żyć, ale na dłuższą metę to głupio się przyznać, że nie potrafi się zrobić pełnego builda, i trzeba było się tym zająć.

Problem leżał w fakcie, że modyfikowaliśmy plik Humans.MDS z animacjami ludzi, dopisując do niego nowe animacje, ale okazało się, że sam oryginalny plik korzysta z kilku animacji, których nie ma w MDK i blokowało nas to przed rekompilacją pliku MDS. Głęboki research po niemieckich, rosyjskich i polskich forach pozwolił nam w końcu rozwiązać ten problem, więc jeżeli trafiłeś tutaj z Google, szukając Hum_TurnL_A05.asc, to już nie musisz szukać dłużej:

  • Hum_TurnL_A05.asc zamieniamy na Hum_TurnL_M03.asc 

  • Hum_TurnR_A05.asc zamieniamy na Hum_TurnR_A03.asc 

  • Hum_Amb_1hRunT0_A01.asc nie ma w MDK analogu, ale można ją pobrać z tego wątku na themodders.org i zamienić na HUM_AMB_1HRUNT0_M01.asc

Po poprawie tych 3 brakujących animacji w pliku MDS byliśmy w stanie go wreszcie przekompilować całkowicie, co odblokowało nam pełne buildy i otworzyło drogę do ponownego wykorzystania Jenkinsa. Ze względu na to, że budowanie modyfikacji wymaga samej gry, to zdecydowaliśmy się na serwerze zainstalować go w jednym miejscu i tylko tam uruchamiać buildy po jednym naraz, co jest absolutnie wystarczające.

Do zdefiniowania kroków, jakie Jenkins musi wykonać ponownie użyliśmy PowerShella, bo całkiem dobrze nam służył do tej pory, a oferując dostęp do biblioteki .NET, pozwalał nam na łatwe uruchamianie zewnętrznych procesów i asynchronicznego przechwytywania ich standardowego wyjścia, żeby wypisać do logów. Kod dla zainteresowanych można znaleźć tutaj, a wykonywane kroki sprowadzają się do:

# Pełna kompilacja assetów
gmbt compile --full --hooks-forward-parameter="$LOCALE" -V detailed

# Test skompilowanych plików jest wymagany, żeby powstały pliki DAT
# --merge=none nie odpala hooka tools\clean-compiled.bat
# Obok działa pętla nasłuchująca na "Skompilowano:*", żeby zabić proces po kompilacji DAT-ów
gmbt test --merge=none -V detailed 

# Budowanie paczki do VDFSa
# --skipmerge nie odpala hooka tools\clean-compiled.bat
gmbt pack --skipmerge -V detailed

# Budowanie ZIPa do dystrybucji
powershell ./BuildRelease.ps1 release/NH.ini $VERSION

No i mamy to — mod do Gothica budowany automatycznie na Jenkinsie bez nadzoru żadnej istoty białkowej. 

Interfejs systemu Jenkins z buildem modyfikacji
Interfejs systemu Jenkins z buildem modyfikacji

Myxir

Myxir to projekt zrodzony bardziej z zabawy niż konieczności. Zaczął się od zabawy z tworzeniem parsera do skryptów wyciągającego z nich różne informacje, a po zdaniu sobie sprawy, że wyciągnąć można całkiem dużo, zaczęło powstawać narzędzie. Ciężko Myxira jednoznacznie zdefiniować, bo pełni wiele zadań, ale w każdym wykorzystuje swoją znajomość tajemniczego języka Deadalusa. Myxir to program, który analizuje skrypty modyfikacji i buduje z nich bazę danych, na której tworzymy różne szczególne rozwiązania.

Jednym z nich jest baza zadań dostępnych w modyfikacji z automatyczną numeracją, której używamy w nazwach dialogów dla porządku. Myxir wtedy na podstawie tego numeru jest w stanie powiązać poszczególne dialogi z zadaniem, a po ich analizie potrafi zbudować drzewo lub graf przebiegu wszystkich dialogów. Szczerze mówiąc nie mamy wielkiego zastosowania dla tej funkcji, ale trzeba przyznać, że jest przekozacka.

Interfejs programu Myxir - graf dialogowy
Interfejs programu Myxir - graf dialogowy

Kolejną funkcją, już bardziej przydatną, jest narzędzie do tłumaczeń, które potrafi odczytać wszystkie placeholdery w projekcie, a następnie podzielić je na kategorie i dostarczyć wygodny interfejs do wprowadzania zmian. W przypadku tłumaczeń dialogów jesteśmy w stanie też wygenerować drzewko kontekstu, na którym tłumacz widzi cały dialog, więc może tworzyć bardzo koherentny przekład bez głupich błędów wynikających z braku wiedzy o kontekście.

Interfejs programu Myxir - tłumaczenia z kontekstem dialogu
Interfejs programu Myxir - tłumaczenia z kontekstem dialogu

Model danych Myxira pozwala na wiele i w przyszłości chcemy przygotować narzędzie wspierające organizację nagrań dubbingu przez tworzenie list linijek dialogowych dla danej postaci oraz śledzenie postępu w pokryciu ich plikami dźwiękowymi. 

Technicznie Myxir jest aplikacją webową napisaną w Javie (Spring Boot) z frontendem w Angularze i korzysta z PostgreSQL jako bazy danych. Moduł importujący informacje ze skryptów składa się z kilku parserów opartych o ANTLR4, które zamieniają informacje z kodu na obiekty bazodanowe.  

Wygląda fajnie?

Dobre narzędzia i dobry proces pozwalają na sprawną pracę i utrzymanie wysokiej jakości jej efektów, więc staramy się sobie życie ułatwiać, a nie utrudniać. Jeżeli masz podobne podejście, potrafisz programować w Daedalusie i chciałbyś współtworzyć dużą modyfikację do Gothica, to zapraszamy na stronę rekrutacji i wysłanie zgłoszenia. Może to właśnie ty wymyślisz coś nowego do naszego procesu :)

We need you
We need you

Nie zapomnij też wpaść na naszego Discorda, gdzie możemy sobie pogadać.