Artykuł · · 11 min czytania
Dałem agentowi AI dyskietki z Amigi. Po jednym wieczorze Pirates! działało w Unity
Dałem agentowi kodującemu cztery obrazy dyskietek z amigową wersją Sid Meier's Pirates! z 1990 roku i poprosiłem o przeniesienie gry do Unity. Pracował Claude Opus 5.5 w Claude Code. Po jednym wieczorze gra dawała się przejść od ekranu tytułowego przez pojedynek, miasto i żeglugę po mapie do bitwy morskiej, na oryginalnej grafice i dźwięku. Ważniejszy od tempa jest sposób sprawdzenia wyniku: ten sam scenariusz decyzji wykonano na porcie w C# i na oryginalnym kodzie maszynowym uruchomionym w emulatorze procesora. W 802 przebiegach, czyli około 6 milionach porównanych linii, obie wersje pokazały graczowi to samo.
Opisuję tu kolejne etapy tej pracy, bo składają się na metodę, którą da się powtórzyć z inną grą i innym modelem. Na końcu pokazuję drugi projekt wykonany tym samym schematem, port Centurion: Defender of Rome z DOS-a do JavaScriptu. Oba są prywatnymi projektami do nauki: oryginalne gry, ich grafika i dźwięk pozostają objęte prawami autorskimi i nie rozpowszechniam ich.

Ekran tytułowy w Unity. Obraz jest dekodowany w czasie działania z oryginalnych plików gry.
Od obrazu dyskietki do zdekompilowanego kodu
Agent zaczął od rozpoznania plików. Obraz dyskietki Amigi (ADF) to surowa kopia sektorów, więc kilkadziesiąt linii Pythona wystarczyło do wypakowania zawartości. Program gry okazał się plikiem z nakładkami (overlay): jądro siedzi w pamięci na stałe, a trzy kolejne segmenty kodu są doczytywane z dysku w miarę potrzeb. Ghidra, darmowy dekompilator, nie ma wbudowanej obsługi tego formatu. Agent napisał skrypt, który rozmieszcza wszystkie segmenty pod stałymi adresami w jednym obrazie i zamienia 45 pośrednich skoków do nakładek na zwykłe wywołania. Dzięki temu dekompilator zobaczył około 630 funkcji jako jeden program.
Dane gry przyniosły niespodziankę. Pliki z rozszerzeniem .rez mają układ zasobów z komputerów Macintosh, bo amigowa wersja była portem z Maca. Po rozpoznaniu formatu agent wydobył 210 tekstów, 82 listy napisów, dane miast dla sześciu epok, obrazy, animacje, sprite'y i dźwięki. Teksty okazały się najlepszą mapą kodu: numer napisu przekazywany do procedury „pokaż tekst” mówi, który ekran gry obsługuje dana funkcja.
Trzynastu agentów czyta kod, dziewięciu go przepisuje
Zdekompilowany kod liczył blisko 29 tysięcy linii bez nazw funkcji i zmiennych. Główny agent pociął go na kawałki po około 2 tysiące linii i przekazał każdy osobnemu agentowi z tym samym pisemnym zleceniem. Każdy miał zapisać w pliku opis wszystkich funkcji ze swojego fragmentu: dokładne wzory, stałe, zakresy losowania, numery wyświetlanych tekstów oraz zmienne globalne czytane i zapisywane. Zlecenie zawierało zdanie, które okazało się istotne: błędny wzór jest gorszy niż oznaczona luka. Agenci mieli więc zaznaczać, czego nie ustalili, i sprawdzać niejasności w asemblerze. Dekompilator gubił tu wiele argumentów funkcji, ponieważ kompilator sprzed 35 lat używał 16-bitowego typu int.
Drugi etap miał ten sam podział. Dziewięciu agentów przepisało reguły gry na C#, każdy swój zakres adresów. Powstało około 16 tysięcy linii kodu, a stan gry trafił do tablicy bajtów adresowanej tak samo jak pamięć oryginału. Wygląda to mało elegancko, ale usuwa całą klasę błędów. Stary kod swobodnie nakłada na siebie struktury i czyta tablice od bajtu przed ich początkiem. Port na obrazie pamięci można tłumaczyć linia po linii i porównywać z asemblerem, a tablice stałych zostają tam, gdzie kod ich szuka. Rysowanie, dźwięk i sterowanie agenci zastąpili wywołaniami interfejsu, który silnik implementuje osobno.
Wyrocznia: oryginalny kod w emulatorze procesora
Lektura zdekompilowanego kodu nie dowodzi, że port działa jak oryginał. Agent zbudował więc narzędzie porównawcze. Oryginalny kod maszynowy procesora Motorola 68000 działa w emulatorze Unicorn. Procedury systemowe, czyli menu, okna tekstowe, odczyt sterowania i rysowanie, zostały podmienione na funkcje w Pythonie. Te funkcje odpowiadają na każde pytanie gry według prostego generatora decyzji i zapisują wszystko, co gra pokazała. Port w C# dostaje identyczny ciąg decyzji przez „gracza skryptowego”, który działa poza Unity. Na koniec oba zapisy porównuje się linia po linii. Teksty zawierają złoto, liczebność załogi, daty i nazwy miast, więc zgodność zapisów jest mocnym testem reguł.
Pierwszy pełny pomiar dał 800 zgodnych przebiegów na 802. Oba rozjazdy miały jedną przyczynę: arytmetykę zmiennoprzecinkową. Gra korzysta z programowej biblioteki, która nie zaokrągla jak dzisiejsze procesory. Jej pierwiastek z 81 wynosi 8,999999999999998, a gra obcina wyniki do liczb całkowitych, więc morale załogi wychodziło w porcie o jeden punkt za wysoko przy niektórych proporcjach złota do załogi. Poprawka polegała na przepisaniu procedur biblioteki instrukcja po instrukcji: 1572 instrukcje, 43 procedury i 21 701 wektorów testowych policzonych przez oryginalny kod, bez żadnej różnicy. Po tej zmianie zgodne są wszystkie 802 przebiegi, a w 120 dodatkowych porównano stan gry klatka po klatce w pojedynku, żegludze oraz bitwach morskiej i lądowej.
Porównanie ujawniło też dwa błędy samej gry z 1990 roku: odczyt mapy przez pusty wskaźnik przy ataku na miasto z ukosa oraz dzielenie przez zero, gdy załoga spadnie do zera. W takich miejscach port musi wybrać zachowanie, a narzędzie porównawcze ma przełącznik, który każe oryginałowi iść tą samą drogą. Wynik ma też granice. Narzędzie nie porównuje rysowania ani dźwięku, a rzadkie ścieżki, takie jak małżeństwo, komplet mapy skarbu czy najwyższe rangi, były ćwiczone słabo.
Prezentacja i dodatki, których w 1990 roku nie było
Warstwę widoczną dla gracza agenci zbudowali w Unity z kodu, bez gotowych scen. Obrazy i animacje są dekodowane z oryginalnych zasobów, sprite'y dostają kolory z palety aktualnego ekranu, a muzykę wyrenderowano do plików dźwiękowych przez emulację czterokanałowego sekwencera gry. Agent sterował uruchomioną grą z wiersza poleceń, robił zrzuty ekranu i je oglądał. Tak wyszło na jaw kilka błędów, których nie łapie żaden test reguł, na przykład brak dźwięku w wersji przeglądarkowej, bo konwersja audio wymagała programu ffmpeg, którego nie było w systemie.

Żegluga po mapie. Tło powstaje z siatki terenu 160 na 200 pól i oryginalnych kafli.
Po pierwszych próbach gry poprosiłem o trzy zmiany. Pytanie o datę przypłynięcia floty skarbów było w oryginale zabezpieczeniem przed kopiowaniem i wymagało papierowej instrukcji; teraz quiz zostaje, ale w „notatkach kapitana” pojawia się poprawna odpowiedź. Lista miast pokazywała tylko dane o mieście, bo gra zakładała, że gracz ma drukowaną mapę; teraz wybrane miasto jest zaznaczone na mapie Karaibów. Ekran pojedynku dostał tabelę z siłą, morale i bronią obu stron, ułożoną jak w oryginale. Dodatki można wyłączyć i testy porównawcze działają bez nich, żeby zapisy nadal zgadzały się z kodem z 1990 roku.

Pojedynek w oryginale. Źródło zrzutu: OpenRetro Game Database.

Ten sam ekran w porcie. Liczby w tabeli są czytane na bieżąco ze stanu pojedynku.

Dodatek względem oryginału: wybrane miasto na mapie Karaibów.
Centurion: ten sam schemat w JavaScripcie i z rozszerzoną rozgrywką
Drugi projekt to Centurion: Defender of Rome, gra dla DOS-a z 1990 roku, przeniesiona do JavaScriptu i elementu Canvas, bez emulatora. Kroki były te same pod innymi nazwami. Plik wykonywalny trzeba było najpierw rozpakować, bo był skompresowany narzędziem EXEPACK. Pięć modułów gry (bitwa lądowa, arena gladiatorów, wyścig rydwanów, bitwa morska i wstęp) siedziało w pliku nakładek i korzystało z jednego wspólnego segmentu danych. Stan kampanii działa na żywej kopii 64 kilobajtów tego segmentu, bo silnik gry polega na 16-bitowym przepełnieniu, dzieleniu całkowitym i bajtach ze znakiem, których liczby zmiennoprzecinkowe JavaScriptu same nie odtwarzają. Zapis gry zgadza się z oryginalnym formatem bajt w bajt, co sprawdzają testy.

Ekran tytułowy Centuriona w przeglądarce, na oryginalnej grafice.
Tu poszedłem dalej niż wierny port. Dodałem przełącznik rozszerzonych reguł, domyślnie wyłączony: łączenie legionów i kilka legionów w jednej prowincji, lekkozbrojnych welitów, słonie bojowe i desant z morza. Kod tego portu pisały inne modele niż w Pirates: najwięcej Gemini 3.8 Flash, a część gpt-6.1-sol od OpenAI uruchamiany przez Codex. Dobór wynikał z narzędzi, które miałem pod ręką, a nie z porównania modeli.

Menu opcji. Pierwsza pozycja włącza rozszerzone reguły, których nie było w oryginale.

Ekran prowincji z menu igrzysk.
Problemy, na które trafił Gemini przy Centurionie
Najwięcej czasu zabrały miejsca, w których zdekompilowany kod albo nazwy plików sugerowały coś innego, niż gra faktycznie robi. Pięć przykładów z notatek projektu:
- Nakładki bez danych. Pięć modułów gry leży w jednym pliku
OVL0.OVLi jest doczytywanych pod ten sam adres, ale wszystkie korzystają ze wspólnego segmentu danych. Sam kod nakładki otwarty w Ghidrze nie pokazywał więc żadnych napisów ani tablic. Skrypt skleił każdą nakładkę z segmentem danych w pięć syntetycznych plików wykonywalnych i dopiero wtedy dekompilator powiązał kod minigier ze strukturami kampanii. - Granice podbitych prowincji. Obrysowanie prowincji według mapy obszarów klikanych myszą dawało kanciaste linie, niepasujące do ręcznie rysowanej mapy. Kod pokazał inny mechanizm: w obrazie mapy każda prowincja ma ukrytą ścieżkę o szerokości jednego piksela w ciemnym kolorze, a tablica w danych podaje po jednym punkcie startowym dla każdej z 27 prowincji. Gra przemalowuje tę ścieżkę na kolor imperium, zaczynając od punktu startowego, i port robi to samo.
- Cofające się klatki. Oryginalna pętla gry działała z częstotliwością 9–12 Hz, a przeglądarka rysuje 60 razy na sekundę. W trzech miejscach sprite cofał się na moment o jedną klatkę. Globus we wstępie obraca się przez przesuwanie 14 kolorów palety w 10 krokach, lecz pierwszy krok w tablicy ma wartość zero; przesuwanie palety w każdym kroku dawało szarpnięcie kontynentów co obrót. W marszu kohort i wypadach gladiatorów trzeba było dokładnie odtworzyć kolejność: najpierw klatka pośrednia, potem zmiana pozycji.
- Mylące nazwy plików. Bitmapy
BB1.CBMiBB2.CBMwyglądały na banery bitwy, a narysowane na górze ekranu zasłoniły całe pole walki. Kod ładuje je tylko wtedy, gdy komputer nie ma karty VGA: to pełnoekranowe tła dla starszych kart EGA i CGA. W tej samej procedurze wyszło, że rozstawienie przed bitwą ma dwa etapy. Wybór formacji podmienia listę dostępnych taktyk, a manewr Cannae pojawia się dopiero po wybraniu klina. - Ledwie słyszalna muzyka. Po zdekodowaniu 17 utworów i 24 instrumentów sterownika karty dźwiękowej AdLib melodie były poprawne, ale grały na poziomie około −55 dB. Dezasemblacja sterownika pokazała, że przy każdej nucie odejmuje on jedną czwartą jej głośności od tłumienia zapisanego w instrumencie. Jedna linia kodu przywróciła około 32 dB. Druga usterka leżała po stronie przeglądarki: przy bardzo krótkich nutach anulowanie zaplanowanych zmian głośności w Web Audio API kasowało trwający jeszcze atak dźwięku i wyciszało nutę.

Drugi etap rozstawienia przed bitwą: taktyki dostępne dla formacji klina.
Osobną pracą były wyścigi rydwanów w Circus Maximus. Rydwan nie jest tam jednym obrazkiem. Osobne bitmapy dla każdej kombinacji trzech klas rydwanów, czterech kolorów woźniców, czterech akcji, szesnastu wychyleń na zakręcie i trzech klatek galopu dałyby ponad 2300 klatek, więc twórcy gry zbudowali w pamięci drzewo 16-bajtowych węzłów. Każdy węzeł wskazuje następny element na tym samym poziomie i swoje pierwsze dziecko, ma indeks klatki, tablicę przesunięć dla kolejnych klatek animacji i flagę widoczności. Korzeniem są cztery konie. Kosz rydwanu ma dodatkowe przesunięcie poziome, do którego fizyka zakrętu wpisuje wychylenie od 0 do 15 pikseli, a woźnica jest jego dzieckiem i dziedziczy tę pozycję. Dlatego na łuku woźnica wychyla się razem z koszem, podczas gdy konie biegną dalej w osi toru. Gałęzie z pękającą osią i koziołkującym wrakiem istnieją cały czas, mają tylko wyłączoną flagę widoczności.
![]()
Drzewo sprite'ów rydwanu odtworzone z danych gry. Schemat powstał w projekcie z oryginalnych, zdekodowanych zasobów.
Tor też nie jest zbudowany z kafli. Gra rozwija 72 zapisane pary współrzędnych w 368 punktów kontrolnych, a pozycję w poprzek toru dzieli na 16 pasów. Od pasa zależy bezpieczna prędkość w zakręcie, liczona jako (numer pasa + 15) × 4: przy wewnętrznej ścianie wynosi 60, przy zewnętrznej bandzie 116–120. Wewnętrzny pas daje najkrótszą drogę, ale wejście w nawrót powyżej tego progu wychyla kosz, spycha rydwan na zewnątrz i uruchamia losowanie wywrotki, tym bardziej prawdopodobnej, im większe przekroczenie. Czerwony znacznik na pasku prędkości pokazuje graczowi ten próg.

368 punktów toru i bezpieczna prędkość w zakręcie zależna od pasa.
Co zostało z tej pracy poza grami
Metodę spisałem jako skill, czyli zestaw instrukcji, który agent wczytuje, gdy dostaje podobne zadanie. Zawiera trzy zasady z obu projektów. Reguły gry przenosi się wiernie, a prezentację zastępuje interfejsem. Port zachowuje układ pamięci oryginału. Wynikowi nie ufa się, dopóki nie przejdzie porównania z oryginalnym kodem. Do skilla trafiły też zlecenia dla agentów analizujących i portujących oraz skrypty z projektu Pirates.
Najciekawszy wniosek dotyczy fanów, nie agentów. Kto ma grę, którą lubi, i od lat czegoś mu w niej brakuje, może dziś szybko i tanio przerobić ją po swojemu, bez kodu źródłowego. W Pirates quiz, który wymagał papierowej instrukcji, podaje teraz odpowiedź, i pojawiła się mapa, której gra nigdy nie miała. W Centurionie dołożyłem reguły, których oryginał nie miał: łączenie legionów, słonie bojowe i desant z morza. Skoro reguły gry są już zwykłym kodem w C# albo JavaScripcie, kolejna zmiana jest zwykłą zmianą w kodzie, a nie łataniem binarki. Tak samo można przenieść grę na szybszy silnik albo inną platformę: Centurion działa w przeglądarce, a Pirates buduje się na Linuksa i do przeglądarki. Metoda nie jest też przywiązana do starych gier. Dekompilator, emulator procesora i porównywanie zapisów działają tak samo dla nowszego programu; więcej kodu oznacza więcej pracy, a nie inny sposób.
Źródła
- Materiały własne z obu projektów: notatki z analizy kodu, zlecenia dla agentów, raport narzędzia porównawczego (802 przebiegi, lista znalezionych różnic), opis portu Centuriona i jego specyfikacje. Nie są opublikowane.
- National Security Agency, Ghidra, pakiet do inżynierii wstecznej użyty do dekompilacji obu gier.
- Unicorn Engine, emulator procesorów użyty do uruchomienia oryginalnego kodu 68000.
- OpenRetro Game Database, strona gry Pirates! dla Amigi, źródło zrzutu ekranu z oryginalnej gry.