Como criar um emulador de jogos
Rafael Leandro
Posted on January 31, 2024
Introdução
Tem TL;DR nesse post não apressadinho(a), mas tu pode pular para o tópico "Arquitetura".
Quando eu era criança me lembro de achar em um dos computadores do laboratório de informática um emulador de GBA e uma ROM do Pokémon Yellow. Me lembro de ter feito uma cópia e levado pra casa, onde joguei por muitas horas.
Sempre fui uma criança curiosa, que gostava de saber como as coisas funcionam. Isso foi muito incentivado pelo meu avô, que me dava uma chave de fenda e alguns eletrônicos para desmontar. Essa curiosidade foi evoluindo depois que ele me deu meu primeiro computador, como o sistema operacional funciona? Como o emulador roda um jogo de um console? Eu consigo fazer um igual?
O tempo passou, aprendi a desenvolver, aprendi como muitas dessas coisas funcionam e hoje resolvi compartilhar um pouco das minhas anotações de como um emulador funciona.
CHIP-8
O escolhido foi o CHIP-8, uma linguagem de interpretação criada nos anos 70 por Joseph Weisbecker. Foi utilizado para desenvolver jogos simples e ferramentas educacionais, hoje é conhecido por ser fácil de implementar e é frequentemente usado como uma introdução à emulação de sistemas mais complexos.
Arquitetura
As informações de arquiteturas são muito importantes, os valores serão utilizados no código!
Memória
Tem um total de 4.096 bytes (ou 4KB), mas desse total, os primeiros 512 bytes são reservados para o sistema.
Registradores
Registradores são pequenas unidades de armazenamento dentro da CPU. São utilizados para realizar operações aritméticas, armazenar endereções e etc.
Essa é uma parte importante do que será desenvolvido.
Os tipos de registradores encontrados no CHIP-8:
- Registradores de uso Geral (V0 a VF): a. Possui 16 registradores de uso geral, numerados de V0 a VF. b. Cada registrador é 1 byte (8 bits) e pode armazenar valores de 0 a 255.
- Registrador de uso de memória (I):
- O registrador I é um registrador de 16 bits utilizado para armazenar endereços de memória.
- Registrador do ponteiro de programa (PC):
- É um registrador de 16 bits que armazena o endereço da próxima instrução que vai ser executada.
- Registrador de ponto de retorno (Stack e SP):
- O CHIP-8 possui uma pilha para armazenar o endereço de retorno durante chamadas de blocos (sub-rotinas)
- O registrador SP registra a posição atual na pilha
- Registradores de estado (VF):
- É utilizado como registrador de flags
- Algumas instruções modificam esse registrador para indicar condições especificas, como carry em operações de adição. Esses registradores são cruciais para a implementação do emulador, eles que fornecem a capacidade de armazenar e manipular dados, controlar os fluxos de execução e interagir com a memória, algumas coisas ficam mais claras com a implementação dó código.
Instruções
As instruções são operações que a CPU, no caso o CHIP-8, consegue executar. Cada instrução é uma ação específica que a CPU deve realizar. Deixei pra apresentar a lista um pouco mais a frente, pra evitar sustos ainda nos conceitos.
Ciclo de máquina
O ciclo pode ser resumido em poucos passos:
- Lê a instrução no endereço apontado pelo PC
- Decodifica a instrução para determinar qual operação deve ser executada.
- Executa a operação.
- Atualiza o PC para apontar para a próxima instrução.
- Volta para o passo 1 e repete o ciclo até o termino do programa.
Desenvolvendo o emulador
Vou utilizar Swift, simplesmente porque é a linguagem que mais uso no dia-a-dia e tem uma leitura muito simples. Já desenvolvi o emulador em C++ mas a leitura pode ser um pouco mais complexa pra quem não conhece a linguagem. Aqui não vou falar sobre padrões de projeto, nem definir arquitetura do software, tudo vai ser feito pensando apenas na facilidade de leitura. Escolha a linguagem e adapta tudo pra ela :)
Agora sabendo da arquitetura é preciso implementar os registradores, emular a memória, decodificar as instruções e implementar o ciclo de máquina.
Primeiro os registradores. Criei uma classe com as variáveis para armazenar os registradores e uma função para incrementar o PC.
class Registers {
// Registradores de Uso Geral (V0 a VF)
var V: [UInt8] = [UInt8](repeating: 0, count: 16)
// Registrador de Endereço de Memória (I)
var I: UInt16 = 0
// Registrador do Contador de Programa (PC)
var PC: UInt16 = 0x200 // Valor inicial por convenção
// Registrador do Ponteiro da Pilha (SP)
var SP: UInt8 = 0
// Método para incrementar o Contador de Programa
func incrementPC() {
PC += 2 // O valor aqui é 2 porque as instruções são de 2 bytes
}
}
Implementação da memória
import Foundation
class Memory {
// Tamanho da memória do CHIP-8 (4KB)
static let memorySize: Int = 4096
// Memória do CHIP-8
private var memory: [UInt8]
init() {
memory = [UInt8](repeating: 0, count: Chip8Memory.memorySize)
}
}
Decodificando as instruções
Essa é a parte mais importante e trabalhosa de se fazer, decodificar e implementar as instruções é a parte central do emulador.
O CHIP-8 possui um conjunto de 35 instruções, aqui uma tabela com as instruções e sua representação
Código | Instrução | Descrição |
---|---|---|
00E0 | CLS | Clear the display. |
00EE | RET | Return from a subroutine. |
1nnn | JP addr | Jump to location nnn. |
2nnn | CALL addr | Call subroutine at nnn. |
3xkk | SE Vx, byte | Skip next instruction if Vx == kk. |
4xkk | SNE Vx, byte | Skip next instruction if Vx != kk. |
5xy0 | SE Vx, Vy | Skip next instruction if Vx == Vy. |
6xkk | LD Vx, byte | Set Vx = kk. |
7xkk | ADD Vx, byte | Set Vx = Vx + kk. |
8xy0 | LD Vx, Vy | Set Vx = Vy. |
8xy1 | OR Vx, Vy | Set Vx = Vx OR Vy. |
8xy2 | AND Vx, Vy | Set Vx = Vx AND Vy. |
8xy3 | XOR Vx, Vy | Set Vx = Vx XOR Vy. |
8xy4 | ADD Vx, Vy | Set Vx = Vx + Vy, set VF = carry. |
8xy5 | SUB Vx, Vy | Set Vx = Vx - Vy, set VF = NOT borrow. |
8xy6 | SHR Vx {, Vy} | Set Vx = Vx SHR 1. |
8xy7 | SUBN Vx, Vy | Set Vx = Vy - Vx, set VF = NOT borrow. |
8xyE | SHL Vx {, Vy} | Set Vx = Vx SHL 1. |
9xy0 | SNE Vx, Vy | Skip next instruction if Vx != Vy. |
Annn | LD I, addr | Set I = nnn. |
Bnnn | JP V0, addr | Jump to location V0 + nnn. |
Cxkk | RND Vx, byte | Set Vx = random byte AND kk. |
Dxyn | DRW Vx, Vy, nibble | Display nibble-byte sprite at memory location I at (Vx, Vy), set VF = collision. |
Ex9E | SKP Vx | Skip next instruction if key with the value of Vx is pressed. |
ExA1 | SKNP Vx | Skip next instruction if key with the value of Vx is not pressed. |
Fx07 | LD Vx, DT | Set Vx = delay timer value. |
Fx0A | LD Vx, K | Wait for a key press, store the value of the key in Vx. |
Fx15 | LD DT, Vx | Set delay timer = Vx. |
Fx18 | LD ST, Vx | Set sound timer = Vx. |
Fx1E | ADD I, Vx | Set I = I + Vx. |
Fx29 | LD F, Vx | Set I = location of sprite for digit Vx. |
Fx33 | LD B, Vx | Store BCD representation of Vx in memory locations I, I+1, and I+2. |
Fx55 | LD [I], Vx | Store registers V0 through Vx in memory starting at location I. |
Fx65 | LD Vx, [I] | Read registers V0 through Vx from memory starting at location I. |
Fx75 | LD R, Vx | Store V0 through Vx in the RPL user flags. |
Implementei uma classe que vai ficar responsável por isso
class Instructions {
let registers: Registers
init(registers: Registers) {
self.registers = registers
}
func executeInstruction(withOffset offset: UInt16) {
let opcodeType = (opcode & 0xF000) >> 12
}
}
(opcode & 0xF000): Realiza uma operação bitwise AND entre opcode
e a máscara 0xF000
.
Operações bitwise são utilizados quando precisamos realizar operações em nível de bits. O operador & (AND) compara dois valores utilizando suas representações binárias, cada bit é comparado e retorna 1 quando os bits forem iguais, caso contrário retorna 0.
>> 12: Desloca para a direita 12 posições, movendo o resultado para as posições mais baixas , sendo os bits menos significativos do resultado (LSB).
Com esse valor, é possível saber qual o tipo de instrução deve ser executada.
switch opcodeType {
case 0x0:
if opcode == 0x00E0 {
// CLS - Clear the display
}
default:
break
}
Na tabela de instruções 00E0 representa a instrução para limpar a tela, então aqui deve se implementar a chamada para esse método.
E para cada uma das instruções deve ser feito isso. Como o código ficaria muito grande e esse texto já está maior do que eu esperava, o link para o repositório do Github com o projeto vai está disponível (assim espero) no fim do artigo.
Ciclo de máquina
Fiz uma classe responsável por centralizar tudo, a inicialização da memória, dos registradores e das instruções. Essa classe também vai controlar o ciclo de máquina, que como visto anteriormente: Lê e decodifica a instrução, executa a operação e atualiza o PC.
class Chip8Cpu {
var registers: Registers
var instructionDecoder: Instructions
var memory: Memory
class Chip8Cpu {
var registers: Registers
var instructionDecoder: Instructions
var memory: Memory
func runCycle() {
let opcode = registers.pc
instructionDecoder.executeInstruction(registers: ®isters, opcode: opcode)
registers.incrementPC()
}
}
Renderização gráfica
E por fim, a parte mais especifica de cada plataforma. Na primeira versão que fiz, desenvolvendo em C++ foquei em desenvolver algo multiplataforma, então usei o GLUT para a parte gráfica. Mas aqui o objetivo é explicar o funcionamento, então
A tela do CHIP-8 é uma matriz de 64x32 pixels, esse pixel pode estar ligado ou desligado. Na implementação caso o pixel esteja desligado tem a cor preta, se estiver ligado tem a cor branca.
func renderScreen() {
for row in 0..<32 {
for col in 0..<64 {
let pixelValue = registers.screen[col, row]
print(pixelValue == 1 ? "[*]" : "[ ]", terminator: "")
}
print()
}
}
Essa função exibe [*] quando desligado e [ ] ligado. Aqui deve ser implementada a view na plataforma escolhida.
Agora já é possível testar o que foi feito, basta apenas carregar a rom na memória, passando a URL do arquivo:
let romData = try Data(contentsOf: url)
let romBytes = [UInt8](romData)
registers.memory.replace(subrange: 512..<(512 + rom.count), collection: romBytes)
Por convenção a rom é carregada a partir do byte 512.
O resultado é a tela abaixo:
O CHIP8 possui também um conjunto fixo de sprites que representam os números em hexadecimal.
struct Font {
static let FontSet: [UInt8] = [
0xF0, 0x90, 0x90, 0x90, 0xF0, // 0
0x20, 0x60, 0x20, 0x20, 0x70, // 1
0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2
0xF0, 0x10, 0xF0, 0x10, 0xF0, // 3
0x90, 0x90, 0xF0, 0x10, 0x10, // 4
0xF0, 0x80, 0xF0, 0x10, 0xF0, // 5
0xF0, 0x80, 0xF0, 0x90, 0xF0, // 6
0xF0, 0x10, 0x20, 0x40, 0x40, // 7
0xF0, 0x90, 0xF0, 0x90, 0xF0, // 8
0xF0, 0x90, 0xF0, 0x10, 0xF0, // 9
0xF0, 0x90, 0xF0, 0x90, 0x90, // A
0xE0, 0x90, 0xE0, 0x90, 0xE0, // B
0xF0, 0x80, 0x80, 0x80, 0xF0, // C
0xE0, 0x90, 0x90, 0x90, 0xE0, // D
0xF0, 0x80, 0xF0, 0x80, 0xF0, // E
0xF0, 0x80, 0xF0, 0x80, 0x80 // F
]
}
Antes do carregamento da ROM é só adicionar as fontes na memória
registers.memory.replace(subrange:0..<Font.FontSet.count, collection: Font.FontSet)
E agora as fontes são carregadas:
Para mapear os controles basta implementar as instruções no offset 0xE e com isso temos um emulador CHIP-8 com o funcionamento básico.
Posted on January 31, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.