Engenharia reversa no GBA: Salvando a fase customizada — Parte 8
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 🐦
Depois de toda a nossa gloriosa trajetória fazendo engenharia reversa no jogo do Klonoa, finalmente conseguimos o que queríamos: criamos um web app para customizar as fases do jogo, o klo-gba.js!
A partir desse momento ele já está usável, pois já conseguimos criar novos desafios no jogo. Porém, a nossa atual implementação apresenta uma restrição: ao salvar uma fase customizada, a fase seguinte à ela deixará de funcionar. Por exemplo, se modificarmos a primeira fase, o jogo crashará ao tentar acessar a segunda fase.
Mesmo ciente desse chato bug, lancei algumas versões alfa do klo-gba.js, pois queria obter feedbacks dos usuários, e ver pessoas usando o produto me serve de motivação para continuar a desenvolver.
Eu já suspeitava o que poderia estar causando esse bug, e tinha noção que seria algo bem complexo de se resolver (e bem divertido, diga-se de passagem). Somente no quinto release que esse bug foi corrigido.
Okay, mas o que causa esse bug?
Seguinte… Até então eu aplicava uma abordagem bem simples para salvar as fases customizadas: apenas substituia o buffer original pelo customizado. Assim o jogo consegue carregar a nossa fase sem eu precisar fazer nenhuma configuração adicional.
Porém, temos o seguinte problema: as fases são armazenadas continuamente na memória, isto é, logo após o último byte de uma fase começam os dados da fase seguinte. Então, ao customizar uma fase, o buffer dela pode ficar maior que o original, e assim os últimos dados acabam sobrescrevendo o começo da fase seguinte!
Apesar de mudarmos o tamanho do buffer de uma fase, o GBA é esperto o suficiente para descobrir automaticamente o tamanho do buffer dos dados a serem descomprimidos. Assim não precisamos dizer qual é o novo tamanho dele. Ele descobre sozinho qual é. Logo, alterar o tamanho do buffer, por si só, não é um problema. O real problema é ele estar sobrescrevendo os dados da fase seguinte.
Outro exemplo curioso é que, se sobrescrevemos a primeira fase e, em seguida, a segunda fase, a primeira fase não funcionará mais (pois sobrescrevemos o final dela) e a terceira também não funcionará mais (sobrescrevemos o começo dela). Somente a segunda executará!
Eu primeiro partir dessa hipótese, e só depois busquei evidências que ela realmente é a razão do problema. Para isso eu apenas comparei o tamanho dos buffers da fase original x customizada.
Algo que me chamou atenção foi que, mesmo sem fazer qualquer alteração na fase, o buffer ainda assim fica maior que o original!
Eu até busquei por alguma configurações naquele antigo código em C para ver se conseguiria gerar um buffer menor, que coubesse no espaço original, mas não deu certo… O novo buffer sempre fica maior que o original.
Assim pensei em uma outra abordagem para resolver o problema, que realmente é bem mais complexa, porém, foi a única ideia que me veio em mente: e se fizermos um patch no código original que carrega as fases, para redirecionar o endereço de leitura da fase para uma outra região da memória?
"Patch", no contexto de engenharia reversa, é quando modificamos o assembly originais de um binário. Digamos que seja um monkey patch glorificado…
Tá. E onde onde passaremos a salvar as fases customizadas? Simples!
Apesar da ROM ser bem pequena, no final dela há um grande espaço ocioso, e o usaremos agora para salvar as fases customizadas e as instruções que codificaremos agora!
Wow, isso parece maneiro! Vamos precisar fazer várias coisas de engenharia reversa! Para isso, vou precisar te contextualizar um pouco mais a respeito do processador que estamos trabalhando, o ARM7TDMI.
Vamos falar de ARM7TDMI…
Esse hardware implementa o conjunto de instrução ARMv4, que, por sua vez, suporta dois conjuntos de instruções: Thumb e ARM. A grande maioria dos jogos de GBA (incluindo o do Klonoa) são escritos majoritariamente em Thumb, pois as instruções são mais rápidas de serem executadas e ocupam menos espaço; duas características essenciais quando está trabalhando num hardware limitado como o Gameboy Advance.
Além de Thumb também há as instruções ARM, da qual conseguem executar operações mais complexas, como armazenar números maiores e fazer condicionais mais especializadas.
Para fazer o switch entre Thumb e ARM deve-se usar a instrução bx (Branch and Exchang). Importante: ele recebe como parâmetro um registrado que diz qual será o novo endereço do program counter (PC).
Eu tive bastante dificuldade em descobrir como realizar esse switch, pois a doc oficial da ARM é complexa e cada pessoa da comunidade falava uma coisa diferente; uma das fontes que usei e que realmente me foram úteis foram a doc técnica do emulador no$gba e essa doc sobre ARM.
No quarto capítulo Onde está o tilemap na ROM? nós localizamos a instrução que carrega a fase do jogo. Falarei um pouco mais dela agora.
Aqui está um dos trechos do código para carregar uma fase (o chamaremos de level loader):
08043B0A mov r4,r0 ; move em R4 o valor do R0
08043B0C add r0,r5,4 ; salva em R0 a soma de R5 com 4
08043B0E mov r1,r4 ; move em R1 o valor do R4
08043B10 bl 0805143Ch ; cria uma branch e move o PC para 0805143C
A função chamada em 08043B10 é a que realiza a descompressão da fase, usando as instruções swi que falamos anteriormente. Lembre-se que elas usam como input o endereço apontado pelo R0.
Ou seja, para redirecionar o buffer do tilemap a ser carregado nós precisamos mudar o valor de R0 para o endereço do buffer do tilemap customizado!
Como precisamos de mais espaço para armazenar o nosso level loader customizado, precisaremos mover o program counter para outra região da memória. Além disso, há uma feature bacana em ARM que nos ajuda a ter um código um pouco mais legível: execuções condicionais, e explicarei agora o que são.
No assembly do ARMv7 temos alguns condition flags, tais como N (negative) o Z (zero), da qual certas instruções atualizam de acordo com o resultado dela. Um exemplo dessas instruções é a cmp (compare). Podemos usar junto dessas operações as instruções de desvio condicional de fluxo, como o beq (Branch if Equal).
Porém, há momentos que podemos usar algo mais simples do que desviar o fluxo de instruções, como por exemplo, condicionar a execução de uma única instrução. Nesse caso, podemos aplicar as execuções condicionais.
Para fazer isso, basta acrescentar determinados sufixos à instrução, como eq ("igual") ou ne ("não igual"). Vejamos um singelo exemplo em ARM:
cmp r0,5h ; compara se R0 é igual a 5
; isso atualiza o estado dos condition flagsmoveq r0,10h ; se for, mova em R0 o valor 10
; isso ler o estado dos registradores condicionais
Caso queira ver um pouco mais sobre isso, veja aqui. É possível também fazer execuções condicionais usando Thumb, mas precisa escrever mais.
Originalmente eu escrevi o código em Thumb, e somente depois refiz usando ARM, e aí constatei que ficou mais legível. Para simplificar a postagem, não falarei como era originalmente com Thumb; passarei direto para a versão em ARM.
Okay. Para executar o nosso algoritmo, precisamos carregar num registrador o endereço do tilemap, para assim fazer a comparação se devemos atualizá-lo ou não. O endereço é um valor bem grande, como por exemplo, 0x081B27FC. Apesar de parecer ser algo trivial mover esse número para um registrador, não, não é nada trivial.
No código assembly de exemplo acima, escrevemos algumas constantes direto na instrução: 5 e 10. É o que se chama de immediate value, ou seja, constantes que estão escritas diretamente no payload da instrução. A primeira instrução move para o registrador a constance 5 e a segunda move 10. Simples, não?
Porém, há algumas limitações! Há um tamanho máximo para o immediate value, e esse limite varia de acordo com a instrução e se está em ARM ou em Thumb. Por exemplo, a instrução mov em Thumb pode receber um immediate value de até 8 bits (até 0xFF), enquanto em ARM é de 16 bits (até 0xFFFF). Por exemplo, o código abaixo simplesmente não existe em Thumb, não é possível escrevê-lo e um assembler acusará erro. Porém, em ARM ele é válido:
mov r0,100h ; move para R0 o valor imediato 0x100
Mas nós precisamos armazenar nos registradores endereços da memória, ou seja, valores de 32 bits. Nem mesmo usando instruções ARM caberia! Então como proceder? No primeiro momento eu fiz um código horrível com bitwise (armazenava os primeiros 16 bits, fazia bitwise de 16 dígitos à esquerda, e aí somava com mais 16 bits)… okay, isso funciona, mas deve existir formas melhores de se resolver isso, não?
Yeah, existe sim! Pesquisando mais, fiquei sabendo que nós podemos expandir esses limites usando literal pool. Essa abordagem trata-se em escrever em uma região da memória nossas constantes e a referenciamos na instrução. Por exemplo, se quisermos passar para o registrador um valor maior que 16 bits devemos usar o operando ldr usando como parâmetro o R15 (PC) junto de um offset. A notação para isso é
ldr r0,[r15,#-3] ; carrega em R0 o valor em [endereço do PC menos 3]
Ao escrever isso no no$gba, ele já exibirá na lista de instruções o valor formatado, o que facilita bastante a leitura do código. Veja a foto abaixo para exemplificar:
Com isso o ldr carregará cerca de 32 bits. Mais uma vez, lembre-se que ARM é little endian, por isso a ordem dos bytes a ser carregado é meio confusa.
O 03 00 1F E5
(E51F0003
na tabela) é o código da instrução ldr r0,[r15,#-3]
; guarde isso em mente, pois voltaremos a falar disso logo mais.
Como de costume, temos algumas limitações. Em Thumb o offset pode ser de até 8 bits, e em ARM é de 12 bits. Ou seja, a nossa constante que desejamos carregar deve estar a uma distância de até 2⁸ (256) bits ou de 2¹² (4096) bits do endereço da instrução ldr.
Por alguma razão misteriosa, o no$gba permite que escrevermos diretamente comandos como ldr r0,=1000h
ao editar uma instrução, porém, isso não necessariamente é verdade! Ele não escreve corretamente o literal pool, apenas "faz funcionar". Isso me confundiu bastante enquanto estudava…
Normalmente quem cria e gerencia o literal pool é um assembler, porém, como não temos um assembler, precisamos fazer esse gerenciamento na mão.
Fazendo o patch no level loader
Esse brevíssimo contexto que falei a respeito de ARM é o suficiente para entender a implementação do patch no level loader que faremos a seguir.
Como primeiro passo precisamos mover o PC do level loader original para uma outra região da memória em que podemos armazenar o nosso level loader customizado.
Eu só fui encontrar um espaço vago beeem longe, lá para os endereços em 08367610. Como queria usar instruções ARM (e o level loader originalmente está em Thumb) precisamos usar a instrução bx para fazer o swap, porém, ele só recebe um registrador, e não é possível armazenar o valor 08367610 em um registrador usando uma única linha (tal como explicado anteriormente), e não temos lá tanto espaço para fazer o patch no level loader.
A solução que obtive para poder mover o PC para longe e trocar para ARM foi a seguinte: a instrução bl (Branch with Link) move o PC e é uma das poucas instruções Thumb que pode usar 4 byte de payload, ou seja, o argumento com o endereço que moverá o PC consegue armazenar imediatamente o valor 08367610.
Você percebeu que o nome em extenso da instrução é "Branch with Link"? Ou seja, além de mover o PC para outro endereço, ele também armazena no R14 onde ele estava antes de deslocar. Assim podemos voltar para onde estava, inclusive já em Thumb de volta, usando a instrução bx r14
.
Perceba que sobrescrevemos duas instruções do level load original para armazenar a instrução bl, pois ela ocupa 4 bytes, enquanto a add e mov ocupam cada uma 2 bytes. Posteriormente vamos precisar executar essas duas instruções sobrescritas.
E note também que, nesse primeiro momento, temos disponíveis os registradores R0 e R1, pois eles recebem valores que podemos computar depois.
A rotina que faz o switch de Thumb para ARM é o seguinte:
08367610 mov r0,r15 ; move para R0 o valor do PC (R15 é o o PC)
08367612 add r0,3Ch ; soma em R0 3C
08367614 bx r0 ; move PC para o valor de R0 e faz switch pra ARM
O código acima faz switch para ARM e move o PC para 3C bytes adiante.
Okay, agora precisamos escrever a função que executa as duas instruções originais do level loader e, muito importante: que troca o valor do R0 para o endereço do buffer da fase customizada! Qual será o algoritmo que implementaremos para efetuar essa troca?
Será um bem simples… mas como estamos escrevendo em assembly e sem um assembler, qualquer coisa torna-se imensamente complexa…
Nós teremos uma tabela de constantes, da qual é a nossa literal pool. Ela armazena o endereço da fase original e o respectivo endereço da fase customizado. A organização dela é bem simples: linha N o endereço original, linha N+1 o respectivo endereço customizado.
Nós verificaremos se o valor do R0 está presente nessa tabela checando apenas nas linhas 1, 3, 5... Se estiver, substitui o valor de R0 com a respectiva próxima linha (2, 4, 6…).
Após horas sofrendo em escrever o código em assembly, ele ficou assim:
; código original do level loader (setar em R0 retorno de R4 + 4)
08367650 add r0,r4,4h; cada bloco desses faz o seguinte:
; - carrega em R4 uma linha da tabela
; - compara R4 com R0
; - se for igual, carrega o valor da próxima linha em R008367654 ldr r4,[r15,#-3Ch]
08367658 cmp r0,r4
0836765C ldreq r0,[r15,#-40h]08367660 ldr r4,[r15,#-40h]
08367664 cmp r0,r4
08367668 ldreq r0,[r15,#-44h]0836767C ldr r4,[r15,#-44h]
08367680 cmp r0,r4
08367684 ldreq r0,[r15,#-48h]; código original do level loader
08367688 mov r4,r1; switch para Thumb e para o level loader
0836768C bx r14
Fodástico! Esse código assembly funciona legal! Ele resolve o nosso problema!
Uma dica quando você for escrever o seu próprio assembly no no$gba: como ele não é exatamente "um editor de texto" e mover de linha as instruções não é algo prático, escreva sempre deixando várias linhas em branco, para usar caso eventualmente precise.
Okay. Fizemos na mão o código no no$gba. Mas como fazer o klo-gba.js escrever automaticamente isso na ROM quando formos salvar uma fase customizada?
Para simplificar um pouco, eu optei que cada fase tenha um endereço fixo para armazenar o tilemap customizada, apesar de que até poderia ser algo calculado dinamicamente.
Assim sendo, nos arquivos json-like que armazenam os dados de cada fase, agora temos a property rom.customTilemap
.
Okay. Vamos escrever a função setPatchCustomVisionLoader para escrever automaticamente o patch que já vimos funcionar.
Uma das primeiras coisas a serem feitas é saber se um tilemap customizado foi escrito, para não fazer redirecionamentos desnecessários. Para isso, escrevi uma função bem simples:
const visionHasCustomTilemap = (romBuffer, visionInfo) =>
romBuffer[visionInfo.rom.customTilemap[0]] !== 0x00const setPatchCustomVisionLoader = (romBuffer) => {
const visionsWithCustomTilemap = allVisions.map(visionInfo =>
visionHasCustomTilemap(romBuffer, visionInfo)) ...
}
Além disso, facilita a nossa vida termos uma array contendo o endereço original do tilemap e o respectivo endereço customizado, para assim preencher aquela nossa tabela.
const setPatchCustomVisionLoader = (romBuffer) => {
... const addresses = allVisions.map(visionInfo => ({
custom: mapAddressToRomOffset(visionInfo.rom.customTilemap[0]),
original: mapAddressToRomOffset(visionInfo.rom.tilemap[0]),
})) ...
}
Okay. Agora vem as partes mais interessantes: escrever código assembly na ROM usando JS!
Como vamos escrever o assembly direto, sem passar por um assembler, não podemos usar mnemonics, mas sim devemos usar o código numérico da instrução. Tal como você provavelmente já deve saber, cada instrução assembly é apenas uma sequência de números. Por exemplo, mov r0,r15
é traduzido pelo assembler para 0x4678
. No caso, como o ARMv7 é little endian, os bytes mais significativos ficam à esquerda, ou seja, devemos armazenar [0x78, 0x46]
.
Há várias tabelas que explicam como mapear uma instrução para o número dela, porém, seria uma bosta fazer isso manualmente. Uma forma prática para obter a sequência numérica é com o no$gba. Ao escrever uma instrução, o no$gba exibe o código numérico correspondente.
Primeiramente, devemos fazer o patch do level loader original e escrever o código para fazer o swtich para ARM. Lembre-se que algumas instruções ocupam 4 bytes, enquanto outras ocupam 2 bytes.
const setPatchCustomVisionLoader = (romBuffer) => {
... // set bl to go to our patch
romBuffer.set([0x23, 0xF3, 0x80, 0xFD], 0x43B0C) // bl 08367610h // switch to arm mode and run our code
romBuffer.set([0x78, 0x46], 0x367610) // mov r0,r15
romBuffer.set([0x3C, 0x30], 0x367612) // add r0, 3Ch
romBuffer.set([0x00, 0x47], 0x367614) // bx r0 ...
}
Você notou algo esquisito no código acima? Não!? Pois veja o segundo argumento das chamas de set! Ele especifica onde vamos escrever no buffer da ROM, e estamos escrevendo em endereços como 0x43B0C, porém, como vimos pelo no$gba, os dados da ROM ficam em endereços na ordem de 0x08000000!
O que acontece é o seguinte: o Gameboy Advance mapeia os endereços da ROM para o conjunto de endereços a partir de 0x08, para assim serem executados pelo processador. Porém, como estamos mexendo diretamente na ROM do jogo, não passamos por esse mapeamento. Por conta disso os endereço que estamos escrevendo não começam com 0x08. O que na execução do GBA fica em 0x08043B0C, aqui fica em 0x43B0C.
Okay. Agora devemos escrever a tabela que mapeia o endereço original da fase para o customizado. Eu escrevi algumas funções auxiliares para facilitar a escrever valores constantes.
import { range } from 'ramda'const splitHexValueIntoBytesArray = (hexValue, numberOfBytes) =>
range(0, numberOfBytes).map((i) => {
const shift = i * 8
const byte = (hexValue >> shift) & 0xFF return byte
})const setConstant = (romBuffer, address, value24hexLength) => {
const bytes = splitHexValueIntoBytesArray(value24hexLength, 4)
romBuffer.set(bytes, address)
}const setPatchCustomVisionLoader = (romBuffer) => {
... // write original and custom address of each vision
addresses.forEach(({ custom, original }, index) => {
const offset = 0x367620 + (index * 8) setConstant(romBuffer, offset, original)
setConstant(romBuffer, offset + 4, custom)
}) ...
}
E agora devemos escrever as instruções para modificar o valor de R0.
const setPatchCustomVisionLoader = (romBuffer) => {
... romBuffer.set(
[0x04, 0x00, 0x85, 0xE2],
0x367650
) // add r0, r5, 4h addresses.forEach((_, index) => {
if (visionsWithCustomTilemap[index]) {
const offset1 = 0x3C + ((index * 12) - (index * 8))
const offset2 = 0x40 + ((index * 12) - (index * 8)) romBuffer.set(
[offset1, 0x40, 0x1F, 0xE5],
0x367654 + (index * 12)
) // ldr r4,[r15,#-offset1]
romBuffer.set(
[0x04, 0x00, 0x50, 0xE1],
0x367658 + (index * 12)
) // cmp r0, r4
romBuffer.set(
[offset2, 0x00, 0x1F, 0x05],
0x36765C + (index * 12)
) // ldreq r0,[r15,#-offset2]
}
}) romBuffer.set(
[0x01, 0x40, 0xA0, 0xE1],
0x367660 + (addresses.length * 12)
) // mov r4, r1
romBuffer.set(
[0x1E, 0xFF, 0x2F, 0xE1],
0x367664 + (addresses.length * 12)
) // bx r14
}
Como você deve ter percebido, escrever assembly usando JS não é uma das coisas mais elegantes que existem… Mas funciona! Agora o bug não acontece mais, pois salvamos e carregamos o tilemap customizado em outra região da memória, onde tem espaço o suficiente para não acontecer sobrescrita.
Caso tenha curiosidade, esse é o PR que implementa esse fix (ele é bem pequeno, apenas 75 novas linhas).
FANTÁSTICO!!
Agora a nossa ferramenta está completamente completíssima!!!
Com isso enfim terminamos essa nossa longuíssima jornada de engenharia reversa num jogo de Gameboy Advance! Nossa, foram 8 capítulos bem extensos. Mas calma, pois ainda terá o 9° capítulo para concluírmos tudo!
Afinal, nós desenvolvemos um novo produto, mas qual foi o feedback que obtivemos com os usuários? E o que nós, como desenvolvedores, aprendemos nessa trajetória? Como você também pode criar foco ao longo de um ano para desenvolver um projeto pessoal e 4fun como esse?
Todo essa análise ficará para o nosso capítulo de encerramento. Vamos lá!