DEV Community

Cover image for Como criar um emulador de jogos
Rafael Leandro
Rafael Leandro

Posted on

Como criar um emulador de jogos

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?

Pokémon Yellow

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.

Space Invaders no CHIP-8

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:

  1. 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.
  2. Registrador de uso de memória (I):
    1. O registrador I é um registrador de 16 bits utilizado para armazenar endereços de memória.
  3. Registrador do ponteiro de programa (PC):
    1. É um registrador de 16 bits que armazena o endereço da próxima instrução que vai ser executada.
  4. Registrador de ponto de retorno (Stack e SP):
    1. O CHIP-8 possui uma pilha para armazenar o endereço de retorno durante chamadas de blocos (sub-rotinas)
    2. O registrador SP registra a posição atual na pilha
  5. Registradores de estado (VF):
    1. É utilizado como registrador de flags
    2. 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:

  1. Lê a instrução no endereço apontado pelo PC
  2. Decodifica a instrução para determinar qual operação deve ser executada.
  3. Executa a operação.
  4. Atualiza o PC para apontar para a próxima instrução.
  5. 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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

(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
}
Enter fullscreen mode Exit fullscreen mode

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: &registers, opcode: opcode)
        registers.incrementPC()
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
        }
    }
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Por convenção a rom é carregada a partir do byte 512.
O resultado é a tela abaixo:

Emulador CHIP8 sem as fontes

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
    ]
}
Enter fullscreen mode Exit fullscreen mode

Antes do carregamento da ROM é só adicionar as fontes na memória

registers.memory.replace(subrange:0..<Font.FontSet.count, collection: Font.FontSet)
Enter fullscreen mode Exit fullscreen mode

E agora as fontes são carregadas:

Emulado CHIP8 com as fontes

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.

Top comments (0)