Engenharia reversa no GBA: Extraindo os objetos da fase — Parte 6
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 🐦
Em nossa última postagem, finalizamos sobre a extração do tilemap de uma fase, porém, uma fase tem mais do que somente o tilemap. Outra importante feature dela são os objetos, como os diamantes e monstros. Tal como já explicado no terceiro capítulo, os objetos são mapeados numa seção da memória chamada de OAM (Object Attribute Memory).
Então bora incluir em nossa POC a plotagem dos objetos, para ficar ainda mais completo do que apenas plotar o tilemap!
Nós podemos começar usando novamente o painel Vram Viewer do no$gba, para assim debugar o OAM visualizando o endereço da memória em que um monstro está armazenado.
E como podemos observar, esse monstro está no endereço 07000008. É o esperado, já que estamos visualizando apenas dois objetos (Klonoa e o monstro), o Klonoa sempre é o primeiro objeto, e cada objeto do OAM ocupa cerca de 8 bytes.
Okay. Já estamos no sexto capítulo, assim percorremos um longo trajeto essa nossa gloriosa engenharia reversa.
Você se lembra do terceiro capítulo? Então. Nele descobrimos que o OAM é atualizado pelo DMA, usando como fonte vários bytes a partir do endereço 03004800. Também descobrimos que os objetos, durante o período de update de cada frame, são substituídos por lixo (instrução 08005D00), e nas instruções subsequentes os objetos são novamente escritos, porém já com os valores atualizados.
Então vamos reaproveitar esse nosso conhecimento acumulado e ir direto debugar step by step de onde sabemos que os objetos são atualizados; buscaremos a instrução que escreve a posição do monstro para descobrir onde fica a fonte de informações de todos os objetos da fase, qual a lógica por trás disso.
Tal como falamos no parágrafo anterior, a atualização dos objetos é feita nas instruções subsequentes da 08005D00. E precisamos ficar de olho onde efetivamente atualiza a posição daquele monstro na fonte de dados do OAM, isto é, atualizar o endereço 03004808 da Fast WRAM (03004800 + 8).
Hey! Ou melhor, no caso, como esse monstro está caminhando horizontalmente, e os bytes referentes à posição X são o terceiro e o quarto, vamos procurar quando os valores dos bytes 0300480A:0300480B são atualizados.
Debugando step by step, podemos observar que a nova posição do monstro é escrita na instrução em 08006C1E. Lendo o assembly e analisando o fluxo de informações, percebemos que faz uma computação para calcular o byte a ser escrito usando como base um valor constante: 03000820. Além disso, também é usada a constante 03002920.
No processo de engenharia reversa, sempre que você encontrar um valor constante, agradeça aos deuses, pois é uma das poucas coisas que você pode garantir para te nortear. E esses valores parecem interessantes. Então vamos para eles ver o que há nessas constantes, começando pelo 03002920!
E olhe só… nós já falamos do 03002920 no terceiro capítulo… Ele é quem armazena a posição X absoluta do Klonoa em relação ao mapa… além disso, descobrimos agora a pouco que alguns bytes mais para frente fica a posição do nosso monstro… isso realmente é algo que chama bastante atenção, não?
E isso não é um acaso! Reparando melhor o comportamento desses bytes, podemos notar que realmente estamos numa seção da memória do jogo que guarda o estado de todos objetos de toda a fase, inclusive do que está fora do alcance da visão do jogador!!
Podemos perceber isso executando o jogo e vendo os valores mudar com um consistente padrão, assim como também podemos agarrar um monstro e ver que o valor de alguns bytes muda conforme o andar do Klonoa — fato que demonstra uma clara correlação. E isso é realmente surpreendente!
Digamos que essa região da memória seria algo como um Global OAM arquitetado pelo pessoal que desenvolveu o jogo. E após um tempo brincando nele, alterando valores e vendo como se comporta, conseguimos entender melhor como funciona. Por exemplo, deu para perceber que cada objeto ocupa cerca de 28 bytes e, curiosamente, o Global OAM também armazena outras informações que eu nem esperava, como os objetos relativos à animação de quando lança o poder de agarrar um monstro.
E creio que, a cada frame, ele verifica o que está visível e na interseção do Global OAM com o a visão do jogador, para verificar se deve incluir ou não no OAM — e assim sabe se deve ou não exibir no display. Isso explica a lógica de por que no update do OAM na VRAM tudo é substituído por lixo e posteriormente pelos valores já atualizados, afinal, é mais fácil apagar tudo e depois ir escrevendo o que deve ser mantido.
Ou seja, com base nessas novas informações, o que previamente achávamos que fosse apenas lixo colocado pela instrução 08005D00 durante o período de update do OAM, na verdade é apenas o valor default de cada slot do OAM para apenas dizer “tem nada aqui”.
Beleza, agora que descobrimos essa região da memória, que decidimos apelidar de "Global OAM", vamos localizar onde exatamente é escrita a posição do monstro no momento que carrega a fase.
É muito fácil localizar um monstro no Global OAM. Basta agarrá-lo e andar com o Klonoa. Assim os bytes da posição X do monstro vai ser atualizado conforme o andar do personagem. E assim encontramos os respectivos bytes que precisamos trackear: 03002AA8.
Assim, vamos trackear quando o monstro é escrito pela primeira vez nesse byte, para assim descobrir onde ficam os dados iniciais do Global OAM na ROM!
Tal como falamos alguns capítulos atrás, a descompressão da fase é feita na instrução em 08051440. E podemos notar que antes dela ser chamada o Global OAM ainda não foi escrito.
Debugando step by step, percebemos que o Global OAM é escrito em etapas. Primeiramente é escrito o objeto do Klonoa, depois alguns outros objetos… E somente após a chamada da função em 08003CD4 os objetos do monstro são escritos. Entrando nessa função, se deparamos com outra função relevante para escrever os monstros, da qual é chamada em 0800B602… Entrando nela, percebemos que a posição do monstro é escrita dentro da função chamada em 0800B634… e finalmente encontramos qual instrução escreve a posição: é a em 0804505C, da qual escreve o valor de R0 no endereço apontado por R3, que no do nosso mostro obviamente caso seria 03002AA8.
Okay. Buscando de onde ele pegou o valor, percebemos que ele usa como base o valor de R5, que previamente recebe o seguinte valor constante: 080E2B64. (Hey… esse parágrafo ficou confuso? Yeah, ficou mesmo. Mas relaxa, apenas mergulhamos de função em função no assembly, e analisamos a passagem de dados de um canto para outro, e o que importa é que encontramos um endereço importante: 080E2B64! Fique feliz com ele!).
E indo para esse tal endereço… Veja só, aqui fica os dados iniciais do objetos de uma fase!
É possível perceber isso apenas reparando semelhanças com os valores do Global OAM e vendo as mudanças propagandas no jogo ao alterar alguns byte.
Digamos que seja o "esqueleto do layout" do OAM da fase.
Por exemplo, aqui conseguimos alterar a posição do spawn de um monstro e o tipo de cada objeto.
Algo curioso é que o ID do tipo daquele objeto não está relacionado com sprite. Assim podemos fazer algumas coisas bem divertidas, como os gifs abaixo ilustram.
A partir de agora chamarei essa nova região da memória de ROM OAM, para diferenciar do Global OAM (OAM na WRAM) e da OAM (que provido pelo próprio GBA).
O fluxo de informação é ROM OAM → Global OAM → OAM.
Outra coisa que reparei é que cada objeto no ROM OAM ocupa bem mais espaço que na do Global OAM: cerca de 44 bytes. A razão disso é que o Global OAM não é tão "Global" assim.
Explicando melhor: cada fase no jogo do Klonoa é divida em algumas etapas, e o Global OAM só guarda as informações dos objetos presentes naquela etapa. Isso faz sentido, para assim poder economizar memória e processamento, já que diminui a quantidade de dados a serem computados a cada frame.
Assim, sempre que o Klonoa muda para alguma outra etapa da fase, a mesma função que popula o Global OAM ao entrar numa fase também é chamada, a fim de resetar o Global OAM e deixá-lo apenas com os objetos presentes naquela etapa — e claro, usando como fonte o ROM OAM, que armazena a os dados dos objetos em todas as etapas da fase.
Resumindo esse bizarro fluxo: a ROM do jogo contém a lista inicial dos objetos de cada fase (“ROM OAM”). O jogo reserva uma seção na WRAM (“Global OAM”) para armazenar o estado atual de cada objeto em uma parte da fase. Quando o jogo carrega uma fase, ele ler a ROM OAM para inicializar os objetos no Global OAM; o Global OAM é atualizado sempre que um objeto é alterado na etapa atual. Por fim, se um objeto tiver que ser exibido na tela, o jogo carrega o sprite dele no OAM. O processador do GBA é responsabilizado por ler o OAM e desenhar os sprites correspondentes na tela.
Que dados seriam esses que o ROM OAM armazena? Seriam todas as posições inicias de cada objeto, uma para cada diferente etapa, além de que tipo aquele objeto é. Ele também armazena algumas outras informações, como por exemplo: caso o monstro seja o Moo, se ele deve começar caminhando para a direita para a esquerda; caso o monstro seja o Flying Moo, o quanto ele consegue voar.
Como descobri isso? Novamente, apenas alterando os bytes e vendo como se comporta no jogo! É bem divertido fazer isso, a propósito.
O diagrama abaixo ilustra o que consegui mapear.
Okay. Localizamos o ROM OAM da primeira fase! E onde será que está o da segunda fase? Será que estaria mais abaixo? Esse é um palpite bem fácil de testar!
E quando fui para baixo, após várias sequências de 00
's, deu para localizar uma seção com vários bytes bem parecidos com o de antes! E alterando alguns desses bytes… yeah! Realmente é o da segunda fase! E fazendo isso novamente, deu para localizar o da terceira fase!!
Legal, agora que entendemos como isso está arquitetado na ROM, vamos plotar na fase! Para isso, vamos modificar o nosso código JS para o seguinte ler essa estrutura de bytes…
Repare que será bem diferente ler o OAM do que ler o tilemap. Enquanto o tilemap apenas precisávamos percorrer um vetor e ir pintando os pixels, no caso do OAM precisamos mapear diferentes bytes para um objeto no JS.
Para facilitar esse novo trabalho, devemos procurar algo no NPM. No primeiro momento eu pensei em usar a biblioteca a qunpack, pois ela me parecia ser mais facilitar, já que ela se assemelha bastante com o unpack do Perl, da qual tenho experiência.
const [
xFirstPosition, yFirstPosition, xSecondPosition, ySecondPosition,
xThirdPosition, yThirdPosition, xFourthPosition, yFourthPosition,
xFifthPosition, yFifthPosition, kind,
] = qunpack.unpack('v2 x4 v2 x4 v2 x4 v2 x4 v2 x5 c', bytes);
Porém, hey, esse código é meio esquisito para qualquer um que não tenha decorado o que caralhos é cada uma dessas letrinhas seguida por um número: v2 x4 v2 x4 v2 x4 v2 x5 c
! Felizmente não estamos programando em Perl, então vamos procurar algo que seja mais semântico no universo de JS!
Ao pesquisar mais, encontrei essa fantástica biblioteca do substack: binary. Com isso, conseguimos ter um código bem mais semântico no JS:
const {
xStage1, yStage1, xStage2, yStage2,
xStage3, yStage3, xStage4, yStage4,
xStage5, yStage5, kind,
} =
binary.parse(memory)
.word16lu('xStage1')
.word16lu('yStage1')
.skip(4)
.word16lu('xStage2')
.word16lu('yStage2')
.skip(4)
.word16lu('xStage3')
.word16lu('yStage3')
.skip(4)
.word16lu('xStage4')
.word16lu('yStage4')
.skip(4)
.word16lu('xStage5')
.word16lu('yStage5')
.skip(5)
.word8lu('kind')
.vars
Beleza, e ao mandar pintar ao checar num pixel que tem um objeto vai funci… ops! Hmm… Não funcionou… Ainda falta alguma coisa…
Oh! Saca só isso: a escala do objetos não é a mesma dos tiles! Eles estão em uma escala de 8x menor, o que pode ser útil para ter um ajuste mais fino de onde exatamente cada objeto deve ficar. Assim, vamos multiplicar o x e y por 8…
Yeah! Deu certo!! Agora, além de plotar o tilemap, plotamos objetos da fase! Agora sim a nossa POC está digna!
Você pode ver o código fonte no meu gist.
Como você viu, extrair o OAM foi extremamente simples, uma vez que já criamos o knowhow a partir de nossa experiência extraindo o tilemap. Além disso, tivemos a sorte de que os dados do OAM de cada fase não está comprimido, o que facilita bastante quando formos ler os dados.
Esse mesmo knowhow de como extrair o tilemap e extrair o objetos pode ser abstraído e usado para obter outros dados da fase, como os portais entre as etapas da fase, assim como também a posição inicial do Klonoa na fase. Não abordarei isso nessas postagens, pois seria realmente bem repetitivo, e temos outras coisas mais divertidas para discutir.
Afinal, na próxima postagem vamos começar a falar de algo bem diferente! Agora que já temos uma POC bem mais completa, vamos começar a codificar o nosso projeto real, começando a plotar o tilemap no browser! A partir da próxima postagem vamos falar menos de como fazer engenharia reversa e mais de como aplicar o que obtemos para ser útil em nosso divertido projeto pessoal, do webapp de editor de mapas para o jogo do Klonoa de Gameboy Advance!