Portuji 27 let starou hru napsanou v C

30. 4. 2025 12:27 Ondřej Novák

Dnešní příspěvek bude o portaci hry Brány Skeldalu, která vyšla v roce 1998. To že to nebyla snadná cesta už dokazuje to, že tento článek vznikl. Pokusím se vypíchnout hlavně různé nástrahy, které si vynutily speciální zacházení

Kdo nezná Brány Skeldalu?

Hra Brány Skeldalu byla poprvé vydána na podzim v 1998. Za produkcí stojí Napoleon Games, což je (nebo spíš byla) společnost založenou Jindřichem Rohlíkem (který si časem změnil příjmení na Skeldal). Má role v tomhle projeku bylo naprogramovat kód. Nebyl to sice můj první projekt, ale rozhodně to byl první velký projekt který se dostal do povědomí širší veřejnosti. A asi leckoho překvapí, že to byl první projekt napsaný v C. Já jsem do té doby programoval Pascalu. Na jazyk C jsem postupně přecházel

Anglická variant úvodního menu

Historie a cíl

Jak jsem už předeslal, kód hry byl napsán v jazyce C. V roce 1996, kdy vývoj začal, byla cílovým operačným systémem MS-DOS s využitím DPMI. Proto byl zvolen Watcom C, který byl dodáván s DOS4/GW. Tento extender umožňoval využít až 64MB paměti. Prostě 640KB paměti určitě nedostačuje každému. Důvodem bylo mimo jiné volba SVGA rozlišení s podporou flat framebuffer (pamětníci si jistě vzpomenou na přepínání bank) a z počátku se počítalo s UniVBE. S postupem vývoje se ale objevovalo víc a víc karet, které podporovaly VESA 2.0, takže se od požadavku na UniVBE odstoupilo. Tento ovladač se pak stal jen jako doporučení pro majitelé karet, kde hra nefungovala, nebyl součástí distribuce.

V roce 2000 vyšla hra v distribuci Game4U

Přeskočme do roku 2004 a vývoj se posunul, stačilo pouhých 6 let a platforma se s hrou stalá obsolete. Všichni jsme měli Windows a podpora nebyla dobrá. Takže jsem se rozhodl, že zkusím hru z vlastní iniciativy ve svém volném čase přeportovat do Windows s použitím DirectX. To se nakonec nějak povedlo, a po několikaměsíčním, ne zrovna jednoduchém, jednání z Jindrou vyšla hra v podobě patche pro majitele oficiálního CD zdarma a dále pak jako vydání pro časopis LEVEL

V roce 2008 byl zdroják uvolnění jako open source a hodně dlouho ležel ladem na SourceForge

V roce 2014 vybral Jindra peníze na Startovači, aby mohl vydat hru na platformy iOS a Android. Tento kód portoval sám Jindra s mou pomocí. Já jsem připravoval kusy kódu v C a on to pak nějak slepoval v marmeládě (Marmalade SDK).

A dostáváme se do roku 2025, kdy hra vychází na Steamu. 

Záměr vydání na Steamu mi Jindra oznámil někdy na přelomu roku s tím, jestli mu nepomůžu to nějak upravit. Souhlasil jsem, ale měl jsem zásadní podmínku – tou bylo kompletní generálka kódu s tím, že cílem je úplná přenositelnost mezi platformami. Primární focus byl Windows a Linux, ale kód hry měl být odprostěn od platformově závislých věcí. Případně potřeby měl být platformově závislý kód oddělen jednotným API a umístěn do separátního stromu v projektu

Proč tahle podmínka? Stál za tím pragmatický důvod. V té době prakticky neexistovala funkční verze. Verze pro Windows z roku 2005 už (prý) nefunguje ve Windows 11. Aby bylo možné do hry přidávat nějaké další funkce, jako třeba Steam achievementy, bylo potřeba, aby se kód dal přeložit. S tím souvisí to, že já ve Windows už prakticky neprogramuju a na drobné věci používám pouze vscode. Dávno nemám možnost například pracovat se sln a vcproj soubory a nechtělo se mi do toho zabředávat. Takže portování na Linux byla logická volba. 

Ono se to ukázalo jako dobré rozhodnutí. Každý překladač (Gcc, Clang, MSVC) si v kódu našel jinou část, na které si rád postěžoval, a tak se hodně upravovalo do „čistější“ struktury.  Stejné pak nasazení různých sanitizérů (MSVC address sanitizer vs valgrind) také odhalilo různé problémy při běhu kódu. Podrobně o tom v dalším odstavci.

Dalším cílem bylo trošku oživit kód pro open source. Mám svůj vlastní github repozitář, kde vystvavuji prakticky veškerou mou práci. A i když ne všechno je 100% funkční, spoustu projektů jsou dnes již zastaralé, tak bych rád udržel nějakou úroveň „standardu“. Tak například jedním z pravidel je snadná instalace ze zdrojáků. To v případě C/C++ vyžaduje aspoň nasazení CMake. Žádne vcproj ani sln do mého repozitáře nepatří. CMake si je případně umí vytvořit během sestavení. Takže tento cíl je zřejmý, pokud má být projekt na stránce mého githubu, bylo potřeba ho revidovat. 

Stránky projektu: ondra-novak/gates_of_skeldal.

K tomu ještě poznámku. Někdy kolem roku 2011 se objevil zájemce, který chtěl přeportovat hru do Linuxu. Jeho repozitář najdete zde: nextghost/skeldal. Zdá se že je pořád aktivní, má tam issue z roku 2024. Já se tedy přiznám, že s nim nejsem v kontaktu, jeho port jsem nepoužil a ani nevím v jakém je stavu. Každopádně bych mu rád připsal nějaký kredit. Trochu je mi ho líto, že jsem mu takhle vzal vítr z plachet

Brány Skeldalu běžící na Linuxu v Ubuntu 24

Hra Brány Skeldalu běží svižně i v Ubuntu (nativně)

Kód obecně

Začnu obecným pohledem na starý kód, kromě věcí, které proberu v dalších odstavcích podrobněji. První věc co mne napadne, když se na kód dívám je:

Už vím proč nesnáším C

Nicméně jedním dechem dodávám, že Rust to nezachrání. Jsem holt už hodně dlouho až po uši v C++, a jakmile člověk má něco někde opravit v C, připadá si, že se ocitl na pustém ostrově bez nástrojů. Já chci objekty, konstruktory, destruktory, automatické kopírování, já chci šablony. Místo toho nesmím zapomínat ke každému malloc psát free. Taková pitomost jako std::vector tam nemám, kód na spoustě míst řeší seznamy různě. Například jako spojové seznamy, nebo jako rozšiřující se pole přes realloc. Nebo jako pole pointerů. Každá tahle věc má jiné API. 

Samotný kód byl rozšiřován o podporu grafiky, hudby, komunikace se steamem, lokalizace, všechny nové věci jsem napsal v C++ a do původního kódu přenesl přes C rozhraní, které objekty exportovalo jako pointery na typové struktury. Například:

typedef struct ini_config_tag INI_CONFIG;
typedef struct ini_config_section_tag INI_CONFIG_SECTION;

INI_CONFIG *ini_open(const char *filename);
void ini_close(INI_CONFIG *config);
const INI_CONFIG_SECTION *ini_section_open(const INI_CONFIG *cfg, const char *section);
const char *ini_find_key(const INI_CONFIG_SECTION *section, const char *key);
const char *ini_get_string(const INI_CONFIG_SECTION *section, const char *key, const char *defval);
long ini_get_int(const INI_CONFIG_SECTION *section, const char *key, long defval);
int ini_get_double(const INI_CONFIG_SECTION *section, const char *key, double defval);
int ini_get_boolean(const INI_CONFIG_SECTION *section, const char *key, int defval);

Implementace těchto dvou objektů je samozřejmě v C++, protože používá std::map, std::vector, std::string, atd. Kdybych tohle měl psát v C,… asi bych začal křičet: vraťte mi někdo ruce!

Na jeden návrhový vzor jsem však v C++ dávno zapomněl. Týká se to struktur alokovaných v jednom paměťovém bloku. Hra ze svých dat načítá často komplikované datové struktury. Například herní obchodníci mají krámky, ty mají zboží, je to taková databáze jak v miniaturním e-shopu. Ale organizované je to ve strukturách, které linkují další struktury. Kód si nejprve spočítá, kolik potřebuje paměti, následně celý blok alokuje a následně realizuje celou tuhle databázi v tomhle bloku s tím, že všechny pointery ukazují do tohoto bloku. Proč se to takhle dělalo? Jednoduše, na konci se celý blok uvolnil. Nebylo nutné rekurzivně procházet všechny závislosti a uvolňovat závislé objekty. 

Mno, přesto si nejsem jist, jestli bych tenhle návrhový vzor chtěl dneska oprašovat. Možná pokud budu potřebovat vysoký výkon.

Video a audio

Pro zajištění maximální přenositelnosti jsem zvolil knihovnu SDL2. Prvotní cílem bylo emulovat původní frame buffer přímo v SDL surface, ale nakonec jsem zvolil jiné řešení. Hra si zachovává vlastní frame buffer tak jak bylo navrženo pro DOS, a aktualizace se řeší přenášením pravoúhelníkových oblastí přes SDL_UpdateTexture. Tato jedna velká textura se pak kreslí do render targetu. Používá se originální barevné schéma 1555 (ARGB), pouze s tím, že původní hra měla význam A=0 ~ viditelné, A=1 ~ transparentní – takže se všem bodům přehazuje nejvyšší bit.

Hra si také sama řešila zobrazování kurzoru myši, v této verzi se kurzor myši řeší v SDL jako další textura, která se zobrazuje z-indexu nad hlavním texturou

Kromě toho SDL část sama řeší různé grafické efekty jako zvětšování výřezu při chození a otáčení – tento kód byl v původní hře napsán v asembleru pro každou grafickou kartu zvlášť. Zvláštní zacházení si vyžádalo i zobrazování přídě loďky, která se ve hře vyskytuje a která se maluje v z-indexu mezi myší a hlavní texturou

Celkem se s SDL po grafické stránce pracovalo dobře. Pomocí jednoduchých efektů jsem také realizoval volitelný „CRT Filter“, což je celoobrazovkový filtr, který simuluje zobrazení na CRT monitoru. Vypadá to pěkně, dýchne na mě nostalgie

Detail na CRT filtru

Detail CRT filtru

Dodatek: Možná někoho napadlo, hra velice využívá zvětšování a zmenšování grafiky ve výhledu, aby se simuloval efekt 3D pomocí 2D obrázků. Toto by se v SDL dalo dělat s podporou HW a třeba se zapnutým lineárním vyhlazování, takže by výsledný efekt nevytvářel kostrbaté artefakty. Musím vás zklamat, i v této verzi to počítají původní softwarové rutiny, byly akorát přepsané z assembleru do C. Tam by bylo potřeba kompletně přepsat správu scény, a na to bylo v danou chvíli málo času. Ale třeba tuto možnost v budoucnu prozkoumám, uvidíme. 

Co se týče audia, v tom mne SDL maličko zklamalo. Audio u SDL2 je řešeno jako jeden vícekanálový stream, který hraje trvale a aplikace musí v callbacku plnit buffer daty, které se mají přehrát. Oproti DirectX portu je to krok zpět, v DirectSound má člověk možnost pracovat s vícero streamy současně a využívá se hardwarové mixování. SDL sice nabízí SDL_Mixer, ale jak jsem pochopil, jedná se od nadstavbu. Softwarové mixování jsem si tedy zajistil sám pomocí několika objektů a šablon v C++.(wave_mixer.h a závislé soubory)

Assembler

Originální hra z velké části používala assembler x86 pro řešení kritických částí programu. Například veškeré vykreslování bylo napsáno v assembleru. Je nutné si uvědomit, že hra běžela v rozlišení 640×480×32768 barev. (tedy 2 bajty na bod). V té době kolem roku 1996 ze začínalo opouštět oblíbené rozlišení 320×200×256 a objevovaly se hry s vyšším rozlišení. A my nechtěli zůstat pozadu. Je třeba si uvědomit, mnou vybraný grafický režim znamenal 10× víc dat, kterých se musel přenést na obrazovku, než u 320×200. Kdo tehdy hrál 3D hru se softwarovým renderingem na 60fps, ten by v našem rozlišení měl 6fps.

To byl důvod, proč byl zvolen assembler. Kromě toho je potřeba přidat poznatek, že optimalizace C v roce 1996 nebyla na dobré úrovni. Dnes bych si netroufnul programovat přímo assembleru. Překladače v tomhle dělají velmi dobrou práci. Už jen znát všechny aspekty superskalárního procesoru a jak za sebou poskládat instrukce, aby z toho byl optimální kód… Že některé posloupnosti instrukcí jsou ve skutečnosti pomalejší, než by se zdálo logické. Dnešní doba je prostě jinde.

Logickým krokem tedy bylo veškerý assembler přepsat do C. Myslel jsem si, že seženu nějaký dobrý dekompilátor, ale nezadařilo se (pokud o něčem víte?). Použil jsem i umělou inteligenci, ale tvrdě jsem narazil, AI prostě dělala chyby, výsledkem bylo vždy něco jiného. Nakonec jsem to tedy řešil ručně. Nějak takto:

int input_code(const void *source,int32_t *bitepos,int bitsize,int mask)
  {
      const uint8_t *esi = source;      //    mov esi,source
      int32_t *edi = bitepos;           //    mov edi,bitepos
      int ebx = bitsize;                //    mov ebx,bitsize
      int edx = mask;                   //    mov edx,mask

      int ecx = *edi;                   //    mov     ecx,[edi]
      int eax = ecx;                    //    mov     eax,ecx
      eax >>=3;                         //    shr     eax,3
      eax = esi[eax] | (esi[eax+1] << 8) | (esi[eax+2] << 16) | (esi[eax+3] << 24);
                                        //    mov     eax,[esi+eax]
      ecx &= 7;                         //    and     cl,7
      eax >>= ecx;                      //    shr     eax,cl
      eax &= edx;                       //    and     eax,edx
      (*edi) += ebx;                    //    add     [edi],ebx
      return eax;
  }

Aligment struktur

Jedním z cílem bylo přeložit hru na x64. V kódu člověk našel i takovéhle konstrukce

short **item_map = malloc(mapsize*4); //32bit pointer má 4 bajty
memset(item_map, 0, 4*mapsize)

Najít všechna ta místa a nahradit to minimálně sizeof() bylo náročné. Možná teď napadne někoho, proč se tento problém neprojevil u předchozích portů. Všechny ty porty byly 32bitové. Verze na Steamu je tedy poprvé 64bitová

Velký problém byl s alignmentem struktur. V době vývoje se s aligmentem nepočítalo, Watcom C ukládal jednotlivé pole struktur za sebou bez vycpávek. Většina datových souborů, která v té době vznikala tak měla formát v podobě binárně serializovaných struktur s nealignovanými prvky. Takové struktury se v aktuálních verzích překladačů načítají špatně, protože u struktur se aplikuje align.

Tohle by mohlo vyřešit #pragma pack(1), ale tím bych si zavřel možnost portace na platformy, kde nealignovaný přístup k proměnné znamená chybu. Takže všechny soubory s binárním formátem se musí po načtení upravit, vložit vycpávky, tam kde mají být, aby to přesně sedělo na struktury s aligmentem. Týká se to všech map, definicí předmětů, nestvůr, akcí, skriptů (které jsou také v binárním formátu).

Příklad:

typedef struct tproduct
  {
  short item;          //cislo predmetu ktere nabizi
  /* short padding1*/
  int cena;           //cena za jeden
  short trade_flags;    //vlajky
  /* short padding2*/                       
  int pocet;           //pocet predmetu na sklade
  int max_pocet;
  }TPRODUCT;

Funkce, která deserializuje tuto strukturu (původně se to mapovalo 1:1)

static __inline void copy_data(const char **src, void *target, int size) {
    memcpy(target, *src, size);
    (*src)+=size;
}

static const char * load_TPRODUCT(const char *binary, TPRODUCT *target) {
    copy_data(&binary, &target->item, 2);
    copy_data(&binary, &target->cena, 4);
    copy_data(&binary, &target->trade_flags, 2);
    copy_data(&binary, &target->pocet, 4);
    copy_data(&binary, &target->max_pocet, 4);
    return binary;
}

Správa resourců

V době, kdy hra vycházela mělo běžné PC asi 8MB. Někdo měl 16MB, někdo 32MB. Kompletní level v paměti zabere v resourců až 24MB, jedná se o grafiky, animace a zvuky. A opět se dostáváme k tomu, že DOS/DPMI nebyl operační systém. Když došla paměť, tak… došla paměť. Toto vyžadovalo opatření z mé strany, nějaký správce resourců

Předně, místo malloc() se používala mnou napsaná funkce getmem()  – reference na Pascal – tato funkce kromě alokace zvládla vyřešit problém nedostatku paměti. Pokud alokace selhala, našla se nějaká nepotřebná paměť a ta se uvolnila. Něco jako garbage collector. Nicméně to co se mohlo uvolnit byly většinou resource v paměti právě v podobě grafiky a zvuků. Ty se totiž daly kdykoliv zase do paměti načíst.

Tyto resource se registrovaly v tabulce podle ID a evidovalo se, zda jsou načtené v paměti nebo ne. Pokud program potřeboval nějakou resource, zavolal funkci ablock(id)  – jako access block. A obdržel pointer na načtenou resource. Samozřejmě pokud resource nebyla v paměti, tak se pro resource alokovala paměť a resource se načetla z disku. 

Funkce ablock garantovala platnost pointeru do dalšího ablock nebo getmem(). Pokud jsem potřeboval garantovat pointer déle, existovala dvojice funkcí alock a aunlock, které bránily uvolnění konkrétní resource z paměti v případě, že paměti byl nedostatek. Manager paměti si musel vybrat jinou resource.

V nastavení hry je volba, která se jmenuje „nahrát vše do paměti“. Hráči s větší paměti tak mohli trochu zrychlit odezvu hry přes loadovací obrazovku. Při přechodu mezi levely se objevila obrazovka s progress barem, kdy se hra snažila načíst co nejvíc resourců do paměti. Během procházení bludištěm se pak už nenačítalo. Tohle asi mělo smysl hlavně pokud se hra hrála přímo z CD, které bylo relativně pomalé. Hráči si hru ale stejně většinou instalovali na HDD. Pokud volba „nahrát vše do paměti“ nebyla aktivní, pak přechody mezi levely sice byla rychlejší, ale pokud na vás z poza rohu vyskočil skřet, chvili bylo doslova vidět, jak se každé jeho animační políčko načítá z disku nebo z CD.

V současném portu byla většina těchto funkci zrušena, protože hra se plně vejde celá do libovolného současného PC. Hlavní soubor obsahující všechny resource má 100MB. Tento soubor se při spuštění mapuje do paměti pomocí mmap (resp MapViewOfFile) a pak se do resourců přistupuje přímým přístupem. Volba „nahrát vše do paměti“ ztratila význam a neprovádí se. Přechody mezi levely jsou tak rychlé, že hráč to často nemusí postřehnout, žádná nahrávací obrazovka tam není. Přesto v kódu zůstaly funkce ablock, alock, aunlock, i když významně zredukované.

Message system a va_args

Následující část popisuje message system, který byl vyvinut pro hru a přidružené nástroje. Je třeba se na to dívat tak, že nejprve vznikaly nástroje, jako editor map, a ty vznikaly pro DOS bez podpory nějakého známého grafického UI – tedy UI bylo komplet mé vlastní.

Když jsem připravoval tuhle sekci, dlouze jsem hledal, odkud přišla inspirace na tenhle systém, protože nápadně připomíná Windows API. Problém je, že ve Win32 jsem se začal učit až v roce 1999. S pomocí umělé inteligence jsem to dal dohromady. Mezi přechodem od Borland Pascalu k jazyku C jsem měl pár let, kdy jsem si „hrál“ ve Windows 3.1 prostřednictvím mnoha vizuálních SW. Vizuální programování bylo tehdy buzz word. A tak jsem se zřejmě nechal ovlivnit Visual Basicem a Microsoft Accessem. Jinak přísahám, na low level Windows API jsem tehdy nesahal!

Message system (v event.h a event.c) má na starost distribuci různých událostí, které mohou vzniknou ve hře, ať už v důsledku interakce uživatele s klávesnicí, myší, ale i časovače, a lze samozřejmě generovat vlastní události a programovat handlery událostí. Nezapomínejme, že jsme pořád v DOSu, kde klávesnici řídil BIOS pomoci patřičné INT 16h instrukce a myš se instalovala jako rezidentní program a také měla vlastní INT 33h. Časovač byl napojen na INT 08h. Žádná message queue, žádný dispatcher.

Editor map pro hru Brány Skeldalu – Port do Windows rok 2005

K dispozici je jedna funkce

send_message(int msgid, ...);

Tato funkce „pošle“ zprávu msgid a parametry (...) někomu, kdo na zprávu může reagovat. Existují dvě vyhrazené zprávy

  • E_ADD - registrace posluchače: send_message(E_ADD, msgid, handler)
  • E_DONE - odebrání posluchače: send_message(E_DONE, msgid, handler)

Na jedné události může poslouchat víc posluchačů. V celém systému pak je definovaných několik dalších zpráv

  • E_INIT - před instalací handleru se handler osloví s touto zprávou.
  • E_IDLE - se volá kdykoliv nejsou žádné zprávy
  • E_WATCH - se volá pravidelně pro kontrolu vstupů. Nezapomínejme, že v DOSu nebyl žádný event systém. Pokud jsem chtěl číst klávesnici a myši, musel jsem pravidelně obcházet patřičné ovladače, a zjišťovat, jestli nebyla stisknuta klávesa, nebo jestli se nepohlo myší. Dneska se takový loop realizuje hlavně u mikročipů. 

Zpravidla se na E_WATCH registroval handler na klávesnici a myš. Pokud se událost detekovala, vznikla událost E_KEYBOARD nebo E_MOUSE. Navíc se kontrolovaly hodiny a pokud nastal „tick“, zavolala se událost E_TIMER

Handler měl tento prototyp:

typedef struct event_msg
  {
  int32_t msg;
  va_list args; //původní deklarace: void *args;
  } EVENT_MSG;

typedef void (*EV_PROC)(const EVENT_MSG *,void **) ;

Při příchodu eventu obdržel každý handler pointer na zprávu a pointer na pointer, který byl asociovaný s handlerem (s jeho registrací). Tam si mohl každý handler uložit libovolnou informaci. Typicky se při E_INIT provedla alokace paměti a pointer se uložil do pointeru druhého parametru. Pak tato paměť byla přístupná pro každý event. Při příchodu události E_DONE mohl handler po sobě uklidit. Pokud tam nechal platný pointer, automaticky na něm udělalo free()

Problémem zde byl přístup k argumentům. V 32-bit systému se parametry ukládají do zásobníku a tedy stačilo místo va_list předat pointer na první parametr. Tohle nejde použít u 64-bit systému. Většina argumentů se předává přes registry. Norma C na toto myslí, a proto byly zavedeny funkce va_start, va_arg a va_end, a také va_copy.

Všechny místa, kde se k parametrům přistupuje přes pointer na args se musely přepsat na va_arg, tato funkce (původně makro) vždy vyzvedne další argument. Problém je, že každý handler si chtěl přečíst stejná data, ale va_arg vždy posune vnitřní ukazatel na další argument. K tomuto účelu právě existuje va_copy. Pro každý handler se tedy provedla kopie přes va_copy. Toto se musel dělat pro každou kopii zprávy – v jazyce, kde není copy constructor :)

Protože message systém je protkán celou hrou, těch míst, kde se takto pracuje s argumenty je opravdu hodně a navíc je bylo těžké odhalit. Tak snad se mi podařilo upravit ve všech handlerech

Následuje (zkrácená) ukázka pro ovládání konzoli (ve hře je možno otevřít ladící konzoli a do ní psát příkazy)

static void console_keyboard(EVENT_MSG *msg, void **_) {
    if (msg->msg == E_KEYBOARD) {
        int code = va_arg(msg->data, int);
        int c = code & 0xFF; //nula dole je extended code
        if (c) {
            int len = strlen(console_input_line);
            if (c == 0x1B) {
               //escape, zavřít konzoli
            }
            if (c == '\b') {
               //backspace, smaž poslední znak
            } else if (c == '\r') {
               //enter
            } else if (c == 3 && !get_shift_key_state()) {
                console_copy_to_clipboard();
            } else if (c >=32 && len < console_max_characters) {
               //přidej další znak do příkazové řádky
            }
        } else {
            switch (code >> 8) {
                //....kurzorové klávesy
                //....
            }
        }
        //tímto způsobem se zabrání distribuci zprávy
        //dalším listenerům
        msg->msg = -1;
    }
}

//instalace
 send_message(E_ADD,E_KEYBOARD, console_keyboard);

//odinstalace
 send_message(E_DONE,E_KEYBOARD, console_keyboard);

Prsaté Driády vám rozstřílí pozadí v soubojovém režimu

Korutiny

Výčet specialit v tomhle podivném kódu nekončí, podíváme se na korutiny. To totiž byl další prvek, který z počátku velmi bránil jakékoliv portaci. Pamatuji si, jak mi kamarád ukazoval přepínání zásobníků na ZX Spectrum s tím, že se jedná o „multitasking“. Jako člověk, co vyrůstal na ZX mne to tehdy zaujalo ale chtěl jsem si to vyzkoušet na PC. První pokusy byly v Pascalu v reálném režimu.

Do kódu hry byly přidané korutiny v pozdější fázi vývoje. Doposud jsem si vystačil se systémem zpráv, ale některé věci se v tom realizovaly obtížně. Jeden reprezentativní příklad jsou ingame animace. Když hráč útočí zbraní nebo kouzlem, přehraje se ve výhledu animace. Kód, který přehrává animace je stejný kód, který přehrává i úvodní a závěrečnou animace a používá stejný formát. Ten formát jsem vymýšlel sám a je inspirován formátem FLI/FLC (Autodesk animator), ale používá LZW kompresi grafiky (jako gif). 

Problém je, že celý přehravač je napsán jako smyčka, která dostane parametrem callback funkci, která dostává informace o eventech v animačím formátu. Jsou tam přitom eventy „přehraj zvuk“ a „zobraz obraz“, podle toho, co se zrovna ze streamu dekódovalo. Veškerý stav přehravače je v zásobníků. Bez dalšího opatření by to znamenalo, že po dobu přehrávání animace by se celý život hry zastavil. Animace by se klíčovala na statický obraz. Ale to nebylo ono. Chtělo to, aby se stav animace zachoval mezi jednotlivými hernímy kroky.

A tak namísto přepisování kódu přehravače – jsme v C, vytvořit si stavový objekt je ohromné množství práce – jsem zavedl korutiny. V našem případě se jmenovaly „task“

typedef void (*TaskerFunctionName)(va_list);

int add_task(int stack,TaskerFunctionName fcname,...);
void term_task(int id_num);
void unsuspend_task(EVENT_MSG *msg);
void task_sleep(void);
void task_sleep_for(unsigned int time_ms);
EVENT_MSG *task_wait_event(int32_t event_number);
int q_any_task(void);
char q_is_running(int id_num);
char q_is_mastertask(void);
int q_current_task(void);

A funguje to přesně tak jak se dá očekávat. Pokud chci spustit korutinu, volám funkcí add_task, předám jí velikost zásobníků (musel jsem odhadnout) a funkci, která se má spustit a její parametry, které se předají jako va_list. Funkce se ihned spustí, ale může ve svém těle zavolat task_sleep, čímž se řízení vrátí do tzv master_task, tak se označuje kód v hlavní zásobníku. Pokud master_task zavolá task_sleep, jsou probuzeny všechny uspané korutiny v round-robin schématu. 

Korutina ale může zavolat task_wait_event čímž je uspána dokud někdo pošle zprávu send_message(event_number,...). Pak je korutina probuzena a  funkce vrátí pointer na EVENT_MSG. Korutina tedy může čekat jen na jednu událost současně. V novém portu přibyla i funkce sleep_for, která umožňuje uspat korutinu na určitou doby. Korutina se automaticky ukončí, když doběhne do svého konce, ale lze jí taky zabít přes term_task. Nicméně neprovede žádný úklid (jen uvolní zásobník).

Problém je, že korutiny nejsou nikam přenositelné. Dlouhou dobu tak jakákoliv portace hry byla tabu, protože představa, že si budu měnit zásobník v operačním systém s chraněným režimem, byla neprůchozí. Teprve až když jsem objevil Windows Fibers se tato možnost otevřela. Windows port z roku 2004 tak používá právě Fibers pro přepínání korutin.

A jak je to v současném portu?

Používají se vlákna. Při přepnutí korutiny dojde k předání „peška“. Vlákno, které drží peška běží, ostatní vlákna čekají na futexu (je to atomic::wait, kdyby to někoho zajímalo) na předání peška. Bohužel jsem nenašel lepší přenositelnější řešení. Naštestí od C++11 jsou vlákna standardní součástí a tak je snažší je použít. 

Jestli se ptáte, zda šlo použít C++20 korutiny tak nešlo. Korutiny v kódu počítají se stackful implementací

Problém: SDL protestuje

Nahradit korutiny vlákny je skutečně funkční řešení, i když asi nemusí být nejefektivnější. Naštěstí tato hra nepotřebuje maximální výkon na nejmodernějších počítačích, takže to není něco, co by mne trápilo. Problém jsem měl s SDL.

Kód hry nedělá rozdíl mezi tím, který task (korutina) může kreslit po obrazovce, u SDL tohle bohužel hraje zásadní roli, protože korutiny se emulují vlákny. SDL (speciálně na linuxu) vyžaduje, aby ke grafickému subsystému přistupovalo jedno konkrétní vlákno, zpravidla vlákno, které vytvořilo daný renderer. To se samozřejmě týká všech jeho resourců jako jsou textury, surface ale třeba i okna. Pokud toto není dodrženo, většina API selhává. 

Toto jsem nakonec vyřešil tak, že jsem pro SDL a ovládání UI, jako rozměry okna, maximalizace nebo přepínání do fullscreen (ALT+ENTER), svěřil separátnímu vláknu. Pokud kterákoliv část programu posílá aktualizaci obrazovky, předává se to přes frontu. Je tam doslova buffer, který funguje jako fronta. Aktualizace výřezu znamená, že se do bufferu uloží hlavička zprávy a data, která se mají aktualizovat a pošle se do SDL vlákna signál. Na to vlákno reaguje, vyzvedne zprávu a data a provede aktualizaci a překreslení. Tomuto vláknu říkám display server. 

Použití display serveru umožňuje v budoucnu portovat hru na headless systémy, které by mohly svůj obrazový obsah posílat na display přes rouru nebo TCP spojení??? Je to vtip, ale šlo by to, proč ne :)

Hlavní výhodou separátního vlákna je, že je ovládání okna responzivní bez ohledu na stav hry. Pomohlo to i při ladění, protože SDL v Linuxu vytváří problémy při ladění události kliknutí myši. Typicky dojde k blokaci myši, dokud je program laděn. Jakmile jsem ale založil SDL vlákno, pak při ladění při použití non-stop gdb mode k těmto problémům nedochází.

Budoucnost a závěr

Může mít tahle věc ještě nějakou budoucnost? 

Byl zveřejněn nový repozitář. Kód hry je nyní pod licenci MIT. Nebyly přeportovány nástroje pro tvorbu nových kampaní. Ty existující kampaně, jako třeba Magika a Skřetí říše by ale měla fungovat, a pokud ne, bude dalším cílem odstraňování chyb v této části kódu. Jestli budou přeportovány nástroje je samozřejmě otázkou budoucnosti. 

Na místě je třeba přiznat, že na správu repozitáře nebudu mít tolik času, jak bych si přál. Ty tam jsou časy, kdy jsem u PC trávil 16 hodin nad jedním projektem. Pracuji pro víc klientů, mám rodinu, dvě pubertální děti, dvě kočky, barák, řeším kotel, řeším střechu, solárka, rekonstrukci koupelny. Takže bych touto cestou rád oslovil i zájemce, kteří by chtěli se kódem zabývat. Upřímně v době, kdy hra vyšla, jsem od toho nečekal velký zájem. Přesto mne i po 27 letech překvapilo, kolik lidí si na hru vzpomíná v dobrém, a kolik lidí se těšilo na nové vydání. 

Hra nově vychází i v anglické jazykové mutaci – jen tak mimochodem, způsob, jakým je realizována jazyková mutace je asi na další článek. 

A to je asi všechno.

… a teď si dáme pivo

Dodatek: Co umí nová binárka

Určitě prostudujte příkazovou řádku  (parametr -h), a konfigurační soubor skeldal.ini. Jsou tam nějaké další možnosti nastavení grafiky. Pokud máte problém se zobrazením, lze v konfiguračním souboru vypnout HW akceleraci a vypnout třeba vyhlazování a filtry. 

Nově lze hru ovládat pomocí „WASD“ fungující jako šipky. Klávesou X se spojuje skupina. Nově přibyly některé operace přes Ctrl+Click (rozdělování skupiny, rychlé obchodování, atd)

Hra má také nově podporu pro ovladač – tedy já osobně to ladil na PS4 ovladači přes BT. O podporu ovladače se stará SDL2 pomocí SDL_JoystickOpen. Očekávejte od toho ale jen jiný způsob ovládání kurzoru myši a mačkání tlačítek. Mapování tlačítek se dá také změnit ve skeldal.ini. K dispozici je jedno MOD tlačítko, takže na každé tlačítko lze namapovat 2 funkce. Pokud budete hledat tlč. na rozdělování skupiny, tak mačkat pravou páčku nad portrétem.

Sdílet