Engenharia reversa no GBA: Entendendo a física do jogo — Parte 3
Essa postagem faz parte da série intitulada Engenharia Reversa num jogo de Gameboy Advance. Leia a introdução aqui. Leia a postagem anterior aqui.
Me siga no Twitter para acompanhar mais computarias 🐦
Nesse momento fiquei realmente sem saber o que fazer, pois o Klonoa atravessar a ponte não era algo que eu esperava, afinal, os tiles estavam ali, nós conseguimos vê-los, mas o Klonoa não conseguia ficar em pé neles!
Então fui fazer um pequeno teste: os tiles que criei permanecem quando eu saio do campo de visão?
Ao fazer esse simples teste, percebemos que a extensão que fizemos da ponte some ao sair do campo de visão… Interessante… Ou seja, além da Fast WRAM, há mais partes da memória que servem de fonte de informação… Mas nesse momento, estava realmente confuso, sem saber como proceder. (Pensando agora enquanto escrevo, talvez eu pudesse ter visto os logs do DMA enquanto caminho… mas não liguei esses pontos, e então segui uma outra estratégia mais difícil)
Sem saber muito no que fazer, decidi partir mais fundo para a engenharia reversa fazendo algo bem mais complexo: entender melhor a lógica de física do jogo, mais especificamente a gravidade e colisão!
A ideia parte da seguinte premissa: quando o Klonoa está caindo, a posição Y dele é atualizada até chegar ao chão. Logo, em algum lugar do assembly deve haver uma condição para saber onde deve parar, e por alguma razão, essa tal condição não está sendo feita nos tiles que criei, desse modo o Klonoa passa direto.
Se entendermos melhor como funciona essa lógica, saberemos como aplicar essa condição nos tiles criados na ponte, fazendo-o funcionar! Desse modo, bora entender como funciona a física no jogo para assim aplicar nos nossos tiles!
Para começar, vamos deixar o Klonoa caindo e ver em que momento o Y dele é atualizado, okay?
Mas antes disso, precisarei explicar um conceito muito importante no GBA: o OAM (Object Attribute Memory).
Além de exibir tiles, podemos exibir objetos na tela. Alguns exemplos de objetos seriam o próprio Klonoa, os monstros, os diamantes… enfim, tudo o que for “dinâmico”. Para oferecer esse dinamismo, precisamos ter uma forma de gerenciá-lo, especificando a posição, o sprite atual… Como isso é um recurso comum em diversos jogos, e o GBA é um hardware especializado para jogos, ele próprio já define em built-in uma arquitetura própria essa gerência, chamado de OAM.
Na região da memória 0700:1kb define-se os OAM. Caso queria entender melhor a organização do OAM, pode ver essa lista.
Assim como na visualização do tilemap, o no$gba tem uma ferramenta muito boa para debugar os OAM, e vamos usá-la agora!
Algo curioso é que o Klonoa sempre é o primeiro objeto no OAM, inclusive em momentos que ele nem aparece, como na tela-título. Vendo a especificação do OAM, sabemos que o primeiro bytes que determina a posição Y no OAM. Desse modo, sabemos que a posição Y do Klonoa sempre estará no byte 07000000, o que facilita a nossa análise. Assim sendo, vamos deixar o Klonoa cair e debugar passo a passo até encontrar que instrução atualizou esse byte.
Debugando passo a passo, percebi que o valor era atualizado numa instrução que não fazia sentido algum: `swi 5h`
Mais para frente explicarei mais detalhadamente o que essa instrução faz, mas podemos abstrair isso por agora. Apenas o que importa é: ela tem nada haver em mudar valores na memória. Após pensar um pouco mais para entender como o byte 07000000 foi atualizado, pensei: é novamente o sapeca do DMA!
O jogo está escrevendo em alguma região da memória os novos valores do OAM do Klonoa, e depois sobrescreve nos bytes em 0700.
Ao abrir os logs do TTY, confirmei a hipótese, conforme pode-se ver na última linha:
DMA3: 03000900 0600E000 80000400
DMA3: 03001100 0600E800 80000400
DMA3: 03004DB0 0600F000 80000200
DMA3: 03004800 07000000 8400003E
Então precisamos monitorar as alterações no byte 03004800! Lembrando que esse byte está na região da memória denominada Fast WRAM.
Indo até ele, um dos testes mais importantes que fiz para entender esse byte foi alterar o valor dele e depois rodar o jogo para ver as implicações.
E então obtemos um efeito interessante: ao atualizar e rodar um frame, realmente mudamos a posição do Klonoa, legal! Porém, o efeito dura somente um único frame, pois no seguinte ele volta a cair de onde estava antes… Ou seja, esse byte realmente é usado para determinar a posição do Klonoa, mas ele usa como fonte de informação algum outro byte que ainda não descobrimos qual é, da qual precisamos encontrá-lo para analisá-lo e assim conseguir entender a física do jogo.
Então comecei a caçar todo o fluxo do byte do 03004800, para encontrar quais instruções lêem e escrevem nele.
A partir desse momento, vou começar a falar bastante de valores escritos em uma outra região da memória: 0800:32mb. Ela se refere aos dados do cartucho, ou seja, do jogo em si, onde ficam armazenadas as instruções (no formato ARMv4T) além dos demais assets.
Executando o debug passo a passo, algo que me chamou atenção ocorre na função chamada pelo byte 08005D00, pois ela sobrescreve o byte 03004800 e tudo por perto dele por lixo!
E somente mais para frente, na instrução do byte 0800696E, é sobrescrito o byte com o valor do Y do Klonoa, mas já atualizado!
Isso foi algo bem misterioso para mim, e também me deixou confuso pois… what!? Como ele conseguiu atualizar o valor se o que tinha antes era somente lixo? De onde ele pegou o valor antigo para atualizar? Ou já pegou o valor já atualizado de outro lugar e apenas copiou aqui?
Para responder essas perguntas, comecei a debugar mais a fundo tudo o que vinha antes da instrução em 0800696E. Porém, o no$gba é bem limitado para essas análises estática, ainda mais nesse nível de complexidade, não sendo possível nem mesmo incluir comentários. Assim comecei a usar o IDA para debugar essas instruções.
Além de poder incluir comentários, outra feature útil no IDA é a visão em grafo, da qual isola em células cada bloco de instruções e ligando-os de acordo com as chamadas.
Após passar várias horas debugando, cheguei a conclusão que para escrever no byte 03004800 ele usa como fonte o valor do byte 03002926 que, debugando passo a passo, percebemos que ele é escrito na instrução em 0800A4C6. Nesse momento, achava que a função onde está a instrução em 0800A4C6 fosse a resposta de toda as minhas perguntas, e a debuguei bem a fundo, porém, após várias horas debugando o assembly (mais a análise que falarei abaixo), cheguei a conclusão que ela apenas determina a posição Y do Klonoa referente à câmera! Isso foi realmente bem frustrante…
Enquanto analisava o byte 03002926, algo que me chamou muita atenção ao acaso foi o comportamento de alguns bytes que ficavam mais à esquerda dele. Reparei que, sempre que movia o personagem, os valores se atualizavam de forma bem coerente com o movimento do personagem, porém, com algumas sutilezas.
Após ficar batendo um pouco mais a cabeça, percebi que os bytes 03002920:03002921 e 03002922:03002923 armazenam, respectivamente, a posição X e Y absoluta relacionada ao mapa! Exatamente o que buscava!! Percebi isso fazendo alguns testes, conforme a imagem abaixo exemplifica.
Perceba que movi o personagem para uma parte um pouco mais alta da fase, e com isso, o valor do Y absoluto diminuiu. Porém, o Klonoa continuou na mesma altura em relação à câmera, e assim o byte relacionado a ela continuou com o mesmo valor.
Ou seja, enfim encontramos os bytes que definem a posição Y absoluta do Klonoa no mapa! E esse novo raciocínio se encaixa bem, pois podíamos ir para um lugar alto na fase ou para um lugar mais baixo, e o valor armazenado em 03004800 (e consequentemente o 07000000 também) não aumentavam proporcionalmente, ou continuavam o mesmo.
Okay, agora que enfim encontramos os bytes que realmente determinam a posição Y que interessa para nós, 03002922:03002923, vamos novamente debugar passo a passo até encontrar onde esses bytes são atualizados.
Assim, cheguei na instrução em 0800FF16. Ela sempre é chamada e serve para aumenta o valor do Y do Klonoa, inclusive quando ele estar fixo no chão, algo bem bizarro e que não faz sentido. Porém, a instrução em 0801200E volta ao valor anterior, e essa instrução só é chamada se ele estiver fixo no chão. Em outras palavras, a lógica é: desce o Klonoa, porém, se a nova posição for inválida, volta para onde tava.
Assim, eu precisava encontrar em que momento é feita a tal condição que determina se chama a instrução 0801200E ou não! Ela responderá o mistério de porque os tiles da ponte não tinham física!
Para isso, usei a visão em grafo do IDA, para saber quais instruções antecediam o 0801200E e se eram chamadas ou não quando o Klonoa estava fixo no chão. Algo parecido com uma busca binária, digamos assim.
O bloco destacado mais abaixo é onde corrige a posição Y do Klonoa, enquanto o bloco destacado mais acima é onde fica a última instrução chamada quando o Klonoa está caindo, ou seja, é onde fica a lógica que determinada “a posição deve ser corrigida ou não?”.
Agora que encontramos isso, vamos decifrar o assembly!
Após analisá-lo, vi que o registrador R0 recebe um valor constante, 0x3D, e o registrador R6 carrega o valor armazena em 03007C8C, da qual é o ID correspondente ao tile que o Klonoa entrará. Então, compara se o valor do R6 é maior que o de R0 para, se for, move para a sequência de instruções que irá corrigir a posição do Klonoa (ou seja, voltar para onde estava antes, tal como já descrito).
Lembrando que tiles vazios tem ID 00, e temos alguns tiles que servem como plano de fundo, como por exemplo, o que exibe algumas placas ao fundo; ou seja, tiles com valores menores que 3D devem ser “atravessáveis”.
O byte 03007C8C é escrito na instrução 0800FF80, da qual copia o valor do registrador R0. E muito importante: ele usa como fonte o tilemap armazenado na Slow WRAM (região 0200:256kb), da qual armazena todo o tilemap da fase, diferente do que vimos na Fast WRAM, que armazena somente o frame atualmente visível da fase!
Como a Slow WRAM tem um armazenamento maior que a Fast WRAM, e o frame atualmente visível sempre está sendo requisito para a renderização, e podemos até mesmo tentar mover o Klonoa para uma região atualmente não visível na tela, essa organização faz todo o sentido.
Com isso respondemos a nossa pergunta: a física não está sendo aplicada nos nossos tiles pois escrevemos no tilemap da Fast WRAM, e a lógica de física é feita usando como base o tilemap escrito na Slow WRAM!
A solução disso é óbvia: basta escrevermos os nossos tiles na Slow WRAM, e facilmente conseguimos localizá-la vendo o valor do R0 logo antes da instrução em 0800FF80. No caso dessa nossa ponte, fica em 02008432.
E enfim conseguimos criar nossos tiles de forma realmente funcional! õ/
Além disso, os nossos tiles permanecem mesmo após sair do campo de visão. Ou seja, estamos realmente escrevemos mais próximo da fonte!
Acho que vale escrever um breve adendo aqui… Fiquei alguns dias tentando decifrar o assembly até chegar nas conclusões que escrevi aqui. Uma das razões que me fez demorar tanto tempo, além da minha inexperiência no assunto, foi ter passado um tempão me confundindo com os bytes referências à posição Y do Klonoa referente à câmera, ao invés da posição Y absoluta, o que me levou a funções completamente nada haver que fiquei decifrando, tal como explanei brevemente. Vale ressaltar que, num processo de engenharia reversa, você vai se perder indo para caminhos errados, e leva um tempo até enfim se achar novamente. Para isso, precisei voltar atrás vários passos e assim recomeçar algumas vezes. Isso é comum e já vi outros autores dizerem o mesmo.
Mas então, muito legal que enfim conseguimos escrever tiles que realmente tem a física aplicada, e então podemos seguir mais adiante em nosso projeto, que será extrair da ROM o tilemap e então plotar o mapa da fase, para posteriormente editá-lo! No próximo capítulo faremos isso! õ/