Nawigacja
Analiza kodu gry ZX Spectrum na przykładzie Renegade
- Drukuj
- 18 Feb 2013
- Technikalia
- 8625 czytań
- 1 komentarz
Renegade to jedna z najlepszych bijatyk na ZX Spectrum. Spośród innych gier swojego gatunku wyróżnia się wysoką grywalnością i ładną grafiką.
Niniejszy artykuł powstał w swej pierwotnej postaci jako odpowiedź na pytanie zadane na forum. Spróbujemy w nim prześledzić kod Renegade (w wersji 48 kB bo wersja 128 kB się trochę różni) i dojść do tego jaką metodą generowana a następnie rysowana jest grafika poziomów. Informacja ta nie jest może potrzebna zbyt wielu osobom ;) ale mam nadzieję że przy okazji uda mi się pokazać pewne ogólne techniki dotyczące analizy kodu gry które przydadzą się wszystkim „grzebaczom” pragnącym poszukać czegoś w cudzym asemblerowym kodzie.
Jeśli interesują nas techniki wyświetlania grafiki w grze to nasze poszukiwania dobrze jest zacząć od użycia edytora który wyświetli nam pamięć ZX Spectrum w trybie graficznym. Narzędzia takie powstawały już w latach 80-tych jako programy wgrywane np. do pamięci ekranu Spectrum, tak by nie nadpisać danych znajdujących się we właściwej pamięci. Dzisiaj jednak wygodniej skorzystać jest z komputera PC i użyć programu napisanego pod Windows który otworzy nam snapshot z zapisem pamięci Spectrum. Z własnego doświadczenia mogę polecić program Spectrum Graphics Editor (ftp://ftp.worldof...winsge.zip) który wprawdzie nie jest najnowszy i wielu rzeczy które by się przydały nie posiada, ale nic lepszego moim zdaniem nie postało.
Wczytujemy więc grę Renegade do emulatora ZX Spectrum, rozpoczynamy grę (aby wygenerował nam się poziom) i zapisujemy stan gry pod postacią snapshota, najlepiej w formacie .SNA gdyż snapshoty .Z80 potrafią być skompresowane i w takim przypadku oryginalnej grafiki z gry w edytorze nie zobaczymy.
Snapshot otwieramy w SGE i początkowo widzimy coś co przypomina szum na ekranie starego telewizora :) Aby zobaczyć jakieś sensowne kształty reprezentujące grafikę gry musimy poeksperymentować w edytorze z szerokością kolumny i adresem początkowym. Mi po kilku próbach udało się uzyskać coś takiego:

Nie wiem jak inni ;) ale ja tu potrafię dostrzec bloczki (po angielsku znane jako tiles, a po polsku zwane też czasem płytkami czy kaflami) budujące tło , rozmiarów 8 na 8.
Jeśli przyjrzymy się ekranowi podczas gry to zobaczymy że niektóre takie bloczki jak np. okna czy podłoga powtarzają się wiele razy. Stąd podejrzenie że obszar gry musi być zapisany w postaci jakieś mapy typu:
N11 N12 N13...
N21 N22 N23...
N31 N32 N33...
..... ..... .....
Gdzie np. N21 to numer wyświetlanego bloczka w drugim rzędzie i pierwszej kolumnie.
Pamiętajmy oczywiście że w kodzie asemblera takich struktur jak dwuwymiarowa tablica która by przechowywała wspomnianą mapę nie ma. Nasza mapa będzie ciągiem bajtów i aby dostać numer bloczka leżącego w danym wierszu i i kolumnie j trzeba będzie prawdopodobnie wykonywać obliczenia typu:
adres_bloczka =adres_ początkowy+ i*rozmiar_ wiersza + j
jeśli dane mamy uporządkowane po wierszach (najpierw dane pierwszego wiersza, potem drugiego itp.)
lub
addres bloczka =adres_ początkowy+ i + j*rozmiar_kolumny
jeśli dane mamy uporządkowane po kolumnach (najpierw dane pierwszej kolumny, potem drugiej...)
Tego jak uporządkowane są dane na danym etapie analizy jeszcze nie wiemy ale wkrótce stanie się to dla nas jasne.
Przyjrzyjmy się teraz ekranowi i polu gry. Aby wyraźniej było widać bloczki budujące tło na poniższy fragment zrzutu ekranu nałożyłem siatkę.

Pole gry w Spectrumowych grach jest czasem węższe niż 256 pikseli (pozioma rozdzielczość komputera), ale w przypadku Renegade zajmuje ono całą szerokość ekranu i zaczyna się na samej górze. Dlatego możemy przyjąć że lewy górny bloczek mapy zostanie skopiowany pod adres 16384 - początek pamięci ekranu Spectrum. Sprawdźmy więc w którym miejscu kodu odbywa się to kopiowanie.
Użyjemy dalej emulatora Spin v0.7 (http://www.zophar...file/21232) Wersja programu jest ważna, bo wcześniejsze wersje nie potrafią robić pewnych rzeczy. Z tego też względu nie dałem linka do strony World of Spectrum gdyż znajduje się tam starsza wersja programu v0.666.
Wczytujemy grę Renegade do emulatora. Jest menu i leci muzyka. Wchodzimy w debugger i ustawiamy breakpoint: Tools/Debugger/Breakpoints/SetBreakpoint

Typowy breakpoint powoduje że emulator wejdzie do debuggera gdy procesor spróbuje wykonać wskazaną instrukcję kodu. Nasz cel jest inny – wejść do debuggera wtedy gdy będzie miał zapis pod adres 16384.
Ustawiamy pola jak na obrazku. Ważne jest by wyczyścić pole Adres. Inaczej nasz warunek będzie sprawdzany tylko wtedy gdy wykonujemy rozkaz pod danym adresem a chcemy wejść do debuggera zawsze gdy odbywa się zapis pod adres 16384.
Dla osób zainteresowanych zaawansowanymi breakpointami w Spinie podaje linka do ich opisu w języku angielskim W instrukcji do emulatora tego niestety nie znajdziemy bo... takiej nie ma :
http://www.worldo...post224831
Zatwierdzamy breakpoint (emulator spyta czy na pewno) i zaczynamy grę. Emulator powinien prawie od razu wejść do debuggera. Okazuje się że najpierw „ustrzeliliśmy” coś takiego
W tym momencie niestety trzeba znać assembler i mieć trochę doświadczenia w analizie kodu. Zobaczymy wtedy że jest to czyszczenie ekranu. Zwróćmy uwagę na instrukcję LD (HL),L – jest to jedna ze sztuczek sprawiających że kod jest bardziej wydajny, choć i mniej czytelny. Jeśli HL=16384 to L=0 czyli tak naprawdę robimy LD (HL),0 w mniej oczywisty ale szybszy i krótszy o jeden bajt sposób.
Wciskamy w debuggerze przycisk "Kontynuuj" i za chwilę powinniśmy osiągnąć poniższy fragment kodu, oczywiście bez moich komentarzy, które są rezultatem jego dalszej analizy:
Rozpoczynamy teraz staranną i nieco żmudną analizę kodu, krok po kroku. Mój tok rozumowania był następujący:
- w instrukcji 39480 jest RET więc nowa procedura zaczyna się w 39481
- DE jest na początku procedury ustawiane na 22528 - początek pamięci atrybutów
- w rozkazie pod adresem 39503 kopiujemy do wspomnianego obszaru atrybutów wartość przechowywaną w HL
- wartość w HL jest wyliczana tak że czytamy L z (IX+0), ustawiamy 7 bit a H ustawiamy na sztywno H=127; Intuicja podpowiada mi tutaj że na każdym poziomie jest możliwych 128 bloczków i L czytane z (IX+0) może mieć wartości 0-127. Jest to więc najprawdopodobniej numer bloczka.
- patrzę w okienko z rejestrami i sprawdzam wartość rejestru IX. Widzę że IX=31892, czyli tam muszą się zaczynać dane mapy!!!
Sprawdźmy eksperymentalnie czy istotnie tak jest. W tym celu będąc dalej w debugerze zatrzymanym na breakpoincie zmienię parę wartości zaraz po adresie 31892 (czyli starcie danych mapy).
Ja w tym momencie przesiadłem się ze Spina na inny emulator, gdyż praca w debuggerze Spina jest w moim odczuciu trochę niewygodna, (choć to pewnie kwestia gustu). Postanowiłem posłużyć się emulatorem Spectaculator. Na wcześniej poznanym adresie 39481 (początek rysowania planszy) ustawiłem breakpoint (tym razem zwykły), wczytałem Renegade i wybrałem start gry z menu. Gdy emulator wszedł do debuggera zmieniłem w nim zawartość kilku komórek poczynając od adresu 31892 na wartość 20 (wybraną w zasadzie losowo) po czym wybrałem opcję „Kontynuuj” by powrócić do gry.
Efekt:

Działa! Widzimy teraz wyraźnie że plansza jest rysowana kolumnami, od góry do dołu i od lewej kolumny do prawej.
Pojawiają się teraz dalsze pytania - czy adres mapy gry który znaleźliśmy to już ostateczny adres pod którym jest ona przechowywana, czy też jest to jakiś bufor do którego zostały one skopiowane / rozpakowane jeszcze spod jakiegoś innego adresu.
Ale ja już zatrzymam się w tym miejscu i ewentualną dalszą analizę pozostawię czytelnikom :)
Sposób postępowania który co przedstawiłem w tym artykule ma swoją nazwę. Jest to tak zwana inżynieria wsteczna (reverse engineering). Programista który napisał Renegade miał koncepcję i zamienił ją na instrukcje, my poruszamy sie w drugą stronę - na podstawie instrukcji próbujemy odgadnąć jego koncepcję. Jest to trochę żmudne i nie da się ukryć że trzeba się nieźle orientować w assemblerze ale potrafi też dać satysfakcję, gdy coś już nam się uda. A czasem głęboko w kodzie gry zakopane są prawdziwe perełki jak niewykorzystana grafika czy całe poziomy. Ale o tym może napiszę innym razem...
Niniejszy artykuł powstał w swej pierwotnej postaci jako odpowiedź na pytanie zadane na forum. Spróbujemy w nim prześledzić kod Renegade (w wersji 48 kB bo wersja 128 kB się trochę różni) i dojść do tego jaką metodą generowana a następnie rysowana jest grafika poziomów. Informacja ta nie jest może potrzebna zbyt wielu osobom ;) ale mam nadzieję że przy okazji uda mi się pokazać pewne ogólne techniki dotyczące analizy kodu gry które przydadzą się wszystkim „grzebaczom” pragnącym poszukać czegoś w cudzym asemblerowym kodzie.
Jeśli interesują nas techniki wyświetlania grafiki w grze to nasze poszukiwania dobrze jest zacząć od użycia edytora który wyświetli nam pamięć ZX Spectrum w trybie graficznym. Narzędzia takie powstawały już w latach 80-tych jako programy wgrywane np. do pamięci ekranu Spectrum, tak by nie nadpisać danych znajdujących się we właściwej pamięci. Dzisiaj jednak wygodniej skorzystać jest z komputera PC i użyć programu napisanego pod Windows który otworzy nam snapshot z zapisem pamięci Spectrum. Z własnego doświadczenia mogę polecić program Spectrum Graphics Editor (ftp://ftp.worldof...winsge.zip) który wprawdzie nie jest najnowszy i wielu rzeczy które by się przydały nie posiada, ale nic lepszego moim zdaniem nie postało.
Wczytujemy więc grę Renegade do emulatora ZX Spectrum, rozpoczynamy grę (aby wygenerował nam się poziom) i zapisujemy stan gry pod postacią snapshota, najlepiej w formacie .SNA gdyż snapshoty .Z80 potrafią być skompresowane i w takim przypadku oryginalnej grafiki z gry w edytorze nie zobaczymy.
Snapshot otwieramy w SGE i początkowo widzimy coś co przypomina szum na ekranie starego telewizora :) Aby zobaczyć jakieś sensowne kształty reprezentujące grafikę gry musimy poeksperymentować w edytorze z szerokością kolumny i adresem początkowym. Mi po kilku próbach udało się uzyskać coś takiego:

Nie wiem jak inni ;) ale ja tu potrafię dostrzec bloczki (po angielsku znane jako tiles, a po polsku zwane też czasem płytkami czy kaflami) budujące tło , rozmiarów 8 na 8.
Jeśli przyjrzymy się ekranowi podczas gry to zobaczymy że niektóre takie bloczki jak np. okna czy podłoga powtarzają się wiele razy. Stąd podejrzenie że obszar gry musi być zapisany w postaci jakieś mapy typu:
N11 N12 N13...
N21 N22 N23...
N31 N32 N33...
..... ..... .....
Gdzie np. N21 to numer wyświetlanego bloczka w drugim rzędzie i pierwszej kolumnie.
Pamiętajmy oczywiście że w kodzie asemblera takich struktur jak dwuwymiarowa tablica która by przechowywała wspomnianą mapę nie ma. Nasza mapa będzie ciągiem bajtów i aby dostać numer bloczka leżącego w danym wierszu i i kolumnie j trzeba będzie prawdopodobnie wykonywać obliczenia typu:
adres_bloczka =adres_ początkowy+ i*rozmiar_ wiersza + j
jeśli dane mamy uporządkowane po wierszach (najpierw dane pierwszego wiersza, potem drugiego itp.)
lub
addres bloczka =adres_ początkowy+ i + j*rozmiar_kolumny
jeśli dane mamy uporządkowane po kolumnach (najpierw dane pierwszej kolumny, potem drugiej...)
Tego jak uporządkowane są dane na danym etapie analizy jeszcze nie wiemy ale wkrótce stanie się to dla nas jasne.
Przyjrzyjmy się teraz ekranowi i polu gry. Aby wyraźniej było widać bloczki budujące tło na poniższy fragment zrzutu ekranu nałożyłem siatkę.

Pole gry w Spectrumowych grach jest czasem węższe niż 256 pikseli (pozioma rozdzielczość komputera), ale w przypadku Renegade zajmuje ono całą szerokość ekranu i zaczyna się na samej górze. Dlatego możemy przyjąć że lewy górny bloczek mapy zostanie skopiowany pod adres 16384 - początek pamięci ekranu Spectrum. Sprawdźmy więc w którym miejscu kodu odbywa się to kopiowanie.
Użyjemy dalej emulatora Spin v0.7 (http://www.zophar...file/21232) Wersja programu jest ważna, bo wcześniejsze wersje nie potrafią robić pewnych rzeczy. Z tego też względu nie dałem linka do strony World of Spectrum gdyż znajduje się tam starsza wersja programu v0.666.
Wczytujemy grę Renegade do emulatora. Jest menu i leci muzyka. Wchodzimy w debugger i ustawiamy breakpoint: Tools/Debugger/Breakpoints/SetBreakpoint

Typowy breakpoint powoduje że emulator wejdzie do debuggera gdy procesor spróbuje wykonać wskazaną instrukcję kodu. Nasz cel jest inny – wejść do debuggera wtedy gdy będzie miał zapis pod adres 16384.
Ustawiamy pola jak na obrazku. Ważne jest by wyczyścić pole Adres. Inaczej nasz warunek będzie sprawdzany tylko wtedy gdy wykonujemy rozkaz pod danym adresem a chcemy wejść do debuggera zawsze gdy odbywa się zapis pod adres 16384.
Dla osób zainteresowanych zaawansowanymi breakpointami w Spinie podaje linka do ich opisu w języku angielskim W instrukcji do emulatora tego niestety nie znajdziemy bo... takiej nie ma :
http://www.worldo...post224831
Zatwierdzamy breakpoint (emulator spyta czy na pewno) i zaczynamy grę. Emulator powinien prawie od razu wejść do debuggera. Okazuje się że najpierw „ustrzeliliśmy” coś takiego
40521: ld hl, 16384 40524: ld de, 16385 40527: ld bc, 6144 40530: ld (hl), l 40531: ldir
W tym momencie niestety trzeba znać assembler i mieć trochę doświadczenia w analizie kodu. Zobaczymy wtedy że jest to czyszczenie ekranu. Zwróćmy uwagę na instrukcję LD (HL),L – jest to jedna ze sztuczek sprawiających że kod jest bardziej wydajny, choć i mniej czytelny. Jeśli HL=16384 to L=0 czyli tak naprawdę robimy LD (HL),0 w mniej oczywisty ale szybszy i krótszy o jeden bajt sposób.
Wciskamy w debuggerze przycisk "Kontynuuj" i za chwilę powinniśmy osiągnąć poniższy fragment kodu, oczywiście bez moich komentarzy, które są rezultatem jego dalszej analizy:
39480: ret 39481: ld ix, (24444) ;-- pod adresem 24444 musi być początek danych mapy !!! 39485: ld de, 22528 39488: ld c, 32 ;-------- 32 kolumny bloczków 39490: push de 39491: ld b, 17 ;-------- 17 bloczków w każdej kolumnie 39493: push bc 39494: push de 39495: ld l, (ix+0) ;---- L musi być numerem bloczka !!!! 39498: set 7, l 39500: ld h, 127 39502: ld a, (hl) ;------ czytaj spod adresu 127*256+128+L=32640+L 39503: ld (de), a ;------ pisz atrybut bloczka (bo de=22528 a to obszar atrybutów) 39504: ld a, d ;--------- zamień ekranowy adres atrybutów na adres grafiki 39505: and 3 39507: rlca 39508: rlca 39509: rlca 39510: add a, 64 39512: ld d, a 39513: res 7, l 39515: ld h, 16 39517: add hl, hl ;------ znajdź adres grafiki bloczka 39518: add hl, hl 39519: add hl, hl 39520: ld b, 8 39522: ld a, (hl) ;------ tutaj mamy kopiowanie grafiki bloczka 39523: ld (de), a 39524: inc l 39525: inc d 39526: djnz 39522
Rozpoczynamy teraz staranną i nieco żmudną analizę kodu, krok po kroku. Mój tok rozumowania był następujący:
- w instrukcji 39480 jest RET więc nowa procedura zaczyna się w 39481
- DE jest na początku procedury ustawiane na 22528 - początek pamięci atrybutów
- w rozkazie pod adresem 39503 kopiujemy do wspomnianego obszaru atrybutów wartość przechowywaną w HL
- wartość w HL jest wyliczana tak że czytamy L z (IX+0), ustawiamy 7 bit a H ustawiamy na sztywno H=127; Intuicja podpowiada mi tutaj że na każdym poziomie jest możliwych 128 bloczków i L czytane z (IX+0) może mieć wartości 0-127. Jest to więc najprawdopodobniej numer bloczka.
- patrzę w okienko z rejestrami i sprawdzam wartość rejestru IX. Widzę że IX=31892, czyli tam muszą się zaczynać dane mapy!!!
Sprawdźmy eksperymentalnie czy istotnie tak jest. W tym celu będąc dalej w debugerze zatrzymanym na breakpoincie zmienię parę wartości zaraz po adresie 31892 (czyli starcie danych mapy).
Ja w tym momencie przesiadłem się ze Spina na inny emulator, gdyż praca w debuggerze Spina jest w moim odczuciu trochę niewygodna, (choć to pewnie kwestia gustu). Postanowiłem posłużyć się emulatorem Spectaculator. Na wcześniej poznanym adresie 39481 (początek rysowania planszy) ustawiłem breakpoint (tym razem zwykły), wczytałem Renegade i wybrałem start gry z menu. Gdy emulator wszedł do debuggera zmieniłem w nim zawartość kilku komórek poczynając od adresu 31892 na wartość 20 (wybraną w zasadzie losowo) po czym wybrałem opcję „Kontynuuj” by powrócić do gry.
Efekt:

Działa! Widzimy teraz wyraźnie że plansza jest rysowana kolumnami, od góry do dołu i od lewej kolumny do prawej.
Pojawiają się teraz dalsze pytania - czy adres mapy gry który znaleźliśmy to już ostateczny adres pod którym jest ona przechowywana, czy też jest to jakiś bufor do którego zostały one skopiowane / rozpakowane jeszcze spod jakiegoś innego adresu.
Ale ja już zatrzymam się w tym miejscu i ewentualną dalszą analizę pozostawię czytelnikom :)
Sposób postępowania który co przedstawiłem w tym artykule ma swoją nazwę. Jest to tak zwana inżynieria wsteczna (reverse engineering). Programista który napisał Renegade miał koncepcję i zamienił ją na instrukcje, my poruszamy sie w drugą stronę - na podstawie instrukcji próbujemy odgadnąć jego koncepcję. Jest to trochę żmudne i nie da się ukryć że trzeba się nieźle orientować w assemblerze ale potrafi też dać satysfakcję, gdy coś już nam się uda. A czasem głęboko w kodzie gry zakopane są prawdziwe perełki jak niewykorzystana grafika czy całe poziomy. Ale o tym może napiszę innym razem...
Dodaj komentarz
Zaloguj się, aby móc dodać komentarz.
Oceny
Tylko zarejestrowani użytkownicy mogą oceniać zawartość strony
Zaloguj się , żeby móc zagłosować.
Zaloguj się , żeby móc zagłosować.
Brak ocen. Może czas dodać swoją?
Rafale! Bardzo ciekawy i rzeczowo napisany artykul!
W pelni sie z Toba zgadzam, ze reverse engineering jest wciagajacy i pasjonujacy.