Skip to content

joserochadev/chip8Emulator

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

chip8Emulator

I always really liked emulator and old games, so I decided to try to build my own emulator in javascript. For that I used videos, sites, references about chip8 and codes from other devs. So I was able to understand a little bit how an emulator is made.

Captura de tela 2023-05-11 125715

References

Criando um Emulador de CHIP-8 em JavaScript

O CHIP-8 é uma linguagem de programação interpretada que foi popular na década de 1970 para o desenvolvimento de jogos simples. Neste artigo, vamos explorar a criação de um emulador de CHIP-8 em JavaScript.

Pré-requisitos

Para quem deseja seguir com esse artigo é necessário alguns pré-requisitos, como:

  • Estudar e entender como funciona o Chip8. Para isso você deve ler a Documentação do Chip8.
  • Estudar sobre operadores bit-a-bit.
  • Estudar sobre báses numéricas, binário e hexadecimal.

Introdução

O emulador de CHIP-8 em JavaScript é construído com base em um objeto chip8. O emulador utiliza o elemento <canvas> para exibir a saída gráfica do CHIP-8, permitindo que os jogos sejam renderizados e jogados no navegador.

Estrutura do Código

Primeiro definimos a função construtora chip8, que inicializa o emulador e configura o ambiente gráfico. Vamos detalhar as principais partes do código:

Inicialização

const chip8 = function() {
  const cnv = document.getElementById('cnv')

  // Configuração do contexto 2D do canvas
  this.ctx = cnv.getContext('2d')
  this.pixelSize = 10
  this.width = 64 * this.pixelSize
  this.height = 32 * this.pixelSize

  // Configuração do tamanho do canvas
  cnv.width = this.width
  cnv.height = this.height

  // Aqui chamamos o método init()
  this.init()

}

Nessa parte, a função construtora chip8 recupera a referência do elemento <canvas> através do seu ID e configura o contexto 2D para desenho. O tamanho do canvas é definido com base no tamanho dos pixels do CHIP-8 (64x32) e a variável pixelSize. Logo depois é chamado o método init.

Inicialização do CHIP-8

chip8.prototype.init = function() {
  // Cria a memória do Chip8 (4096 bytes)
  this.memory = [4096]
  // Cria um array que pode armazenar os 16 registradores originais do Chip8
  this.register = [16]
  // Cria um array para representar a pilha de processos do Chip8
  this.stack = [16]
  // Cria a variável index que é utilizada por algumas instruções do Chip8
  this.index = null
  // Cria a matriz do diplay do chip8
  this.display = new Array(64 *32)
  // Variável que diz quando algo pode ser desenhado na tela
  this.displayFlag = false
  this.step = 0
  
  this.pc = 0x200   // contador de programa
  this.sp = null    // stack pointer

  // timers
  this.delayTimer = 0
  this.soundTimer = 0

  this.opcode = null

  // Todas as fonts suportadas pelo Chip8
  const fonts= [ 
    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
  ]


  // Inicialização da memória com os valores dos caracteres de fonte
  for(let i in fonts){
    this.memory[i] = fonts[i]
  }
}

Nesse trecho, a função init inicializa as estruturas de dados do emulador, como a memória, registradores, pilha, índice e display. Além disso, os caracteres de fonte são carregados na memória.

Carregando um ROM

chip8.prototype.loadRom = function(rom) {
  for(let i = 0; i < rom.length; i++){
    this.memory[0x200 + i] = rom[i]
  }
  
  // Chama o método cycle
  this.cycle()
}

Essa função loadRom é responsável por carregar um arquivo ROM (programa CHIP-8) na memória do emulador. Os bytes do programa são armazenados a partir do endereço 0x200, como é convencional em um emulador CHIP-8.

Ciclo de Execução

chip8.prototype.cycle = function(){
  this.opCode()

  // Nesse trecho está sendo configurado o delay timer
  // Essa parte e importante para configurar a frequência com que algo é desenhado na tela
  this.step++
  if(this.step % 2){
    if(this.delayTimer > 0){
      this.delayTimer--
    }
  }

  // desenha na tela
  if(this.displayFlag){
    for(let i in this.display){
      // configura as coordenadas x, y 
      let x = (i % 64)          // garante que nada vai ser desenhado fora dessa largura
      let y = Math.floor(i / 64) // garante que nada vai ser desenhado fora dessa altura

      // Quando o valor do display for 1, vai ser desenhado um "pixel" preto na tela
      // se não o fundo vai ser desenhado em uma cor verde.
      if(this.display[i] == 1){
        this.ctx.fillStyle = '#000'
        this.ctx.fillRect(
          x * this.pixelSize,
          y * this.pixelSize,
          this.pixelSize,
          this.pixelSize
        )
      }else{
        this.ctx.fillStyle = '#036145'
        this.ctx.fillRect(
          x * this.pixelSize,
          y * this.pixelSize,
          this.pixelSize,
          this.pixelSize
        )
      }
    }

    this.displayFlag = false
  }


  setTimeout(this.cycle.bind(this),1)

}

O método cycle implementa o ciclo de execução principal do emulador CHIP-8. Ele chama o método opCode() para ler e executar a próxima instrução do programa. Em seguida, ele atualiza o display caso haja alguma alteração pendente. Por fim, ele chama novamente o próprio método cycle usando setTimeout para criar um loop contínuo de execução.

Interpretação do OpCode

chip8.prototype.opCode = function() {

  let opcode = this.memory[this.pc] << 8 | this.memory[this.pc + 1]
  /*
  Nessa linha, a variável `opcode` é definida. O `opcode` representa uma instrução
  de 16 bits no Chip-8. Aqui, estamos combinando dois bytes da memória (o byte em 
  `this.memory[this.pc]` e o byte seguinte em `this.memory[this.pc + 1])` para 
  formar o opcode completo. O operador `<<` realiza um deslocamento de bits para
   a esquerda, enquanto o operador `|` faz uma operação de "ou" bit a bit
  */
  let vx = (opcode & 0x0f00) >> 8
  /*
  Nessa linha, a variável `vx` é definida. Ela representa o valor do registrador Vx,
  onde x é um dígito hexadecimal no opcode. Para obter o valor de Vx, usamos uma 
  operação de "e" bit a bit (`&`) com o opcode e uma máscara de bits `0x0f00`.
  Em seguida, realizamos um deslocamento de bits para a direita (`>>`)
   por 8 posições para obter o valor de Vx.
  */
  let vy = (opcode & 0x00f0) >> 4
  /*
    Nessa linha, a variável `vy` é definida. Ela representa o valor do registrador
    Vy, onde y é um dígito hexadecimal no opcode. Assim como antes, usamos uma operação
    de "e" bit a bit (`&`) com o opcode e uma máscara de bits `0x00f0`. Em seguida, 
    realizamos um deslocamento de bits para a direita (`>>`) por 4 posições
    para obter o valor de Vy.
  */

  // Essas operações são usadas para extrair informações específicas do opcode,
  // como quais registradores estão sendo referenciados em uma determinada
  // instrução do Chip-8. Isso permite que o emulador interprete e execute 
  // corretamente as instruções do programa Chip-8.

  // switch responsavel por identificar e executar cada instrução do chip8
  switch (opcode & 0xF000) {
    case 0x0000:
      switch(opcode & 0x00ff){
        case 0x00e0:
          // clear display
          for(let i in this.display){
            this.display[i] = 0
          }
          this.displayFlag = true
          break

        case 0x00ee:
          // Return from a subroutine.
          // The interpreter sets the program counter to the address at the 
          // top of the stack, then subtracts 1 from the stack pointer.
          this.pc = this.stack[this.sp]
          this.sp--
          break
      }
      break
    case 0x1000:
      // Jump to location nnn.
      // The interpreter sets the program counter to nnn.
      this.pc = opcode & 0x0fff
      break

    case 0x2000:
      // Call subroutine at nnn.
      // The interpreter increments the stack pointer, then puts the current 
      // PC on the top of the stack. The PC is then set to nnn.
      this.sp++
      this.stack[this.sp] = this.pc
      this.pc = opcode & 0x0fff
      break

    case 0x3000:
      // Skip next instruction if Vx = kk.
      // The interpreter compares register Vx to kk, and if they are equal,
      //  increments the program counter by 2.

      if(this.register[vx] == (opcode & 0x00ff)){
        this.pc += 2
      }
      break


    case 0x6000:
      // Set Vx = kk.
      // The interpreter puts the value kk into register Vx.
      this.register[vx] = opcode & 0x00ff
      break
    
    case 0x7000:
      // Set Vx = Vx + kk.
      // Adds the value kk to the value of register Vx, then stores the result in Vx.
      this.register[vx] += opcode & 0x00ff
      if(this.register[vx] > 255){
        this.register[vx] -= 256
      }
      break

    case 0xa000:
      // Set I = nnn.
      // The value of register I is set to nnn.
      this.index = opcode & 0x0fff
      break
    
    case 0xd000:
      // Display n-byte sprite starting at memory location I at (Vx, Vy), set VF = collision.

      // The interpreter reads n bytes from memory, starting at the address stored in I.
      //  These bytes are then displayed as sprites on screen at coordinates (Vx, Vy).
      //  Sprites are XORed onto the existing screen. If this causes any pixels to be 
      // erased, VF is set to 1, otherwise it is set to 0. If the sprite is positioned 
      // so part of it is outside the coordinates of the display, it wraps around to 
      // the opposite side of the screen. See instruction 8xy3 for more information 
      // on XOR, and section 2.4, Display, for more information on the Chip-8 screen 
      // and sprites.


      let height = opcode & 0x000f
      let rx = this.register[vx]
      let ry = this.register[vy]

      this.register[0xf] = 0


      for(let i = 0; i < height; i++){
        // pega o pixel da memória que vai ser desenhado
        let currentPixel = this.memory[this.index + i]
        for(let c = 0; c < 8; c++){

          if(currentPixel & 0x80){
            let x = rx + c
            let y = ry + i

            // garante que nenhum pixel seja desennhado fora da tela.
            if(x >= 64){ x -= 64}
            if(x < 0){ x += 64}
            if( y >= 32){ y -= 32}
            if(y < 0){ y += 32}

            if(this.display[y * 64 + x] == 1){
              this.register[0xf] = 1
            }

            this.display[y * 64 + x] ^= 1

          }

          currentPixel <<= 1
        }
      }

      // configura do displayFlag pra true para que a função de desenho possa desenha-lo
      this.displayFlag = true
      break
  }
}

Nessa função, o método opCode lê o próximo OpCode da memória e realiza uma interpretação com base no valor inicial de 4 bits. Dependendo do valor do OpCode, diferentes ações são executadas, como chamadas de função para instruções específicas. Nesse método e muito importante que você tenha conhecimentos sobre operadores bit-a-bit e sobre bases numéricas.

OBS: Aqui estão sendo emuladas apenas algumas instruções do chip8, o suficiente para que possamos emular um programa simples de desenhar uma logo na tela.

OBS: Os comentários em inglês vem da documentação do chip8.

Inicialização e Carregamento de ROM

window.onload = function() {
  const chip = new chip8()

  // Carregando a ROM
 function loadRom(){
    let xhr = new XMLHttpRequest
    xhr.open('GET', './roms/Chip8 Picture.ch8', true)
    xhr.responseType = 'arraybuffer'

    xhr.onload = function() {
      let rom = new Uint8Array(xhr.response)
      console.log(rom)
      chip.loadRom(rom)
    }

    xhr.send()
  }

  loadRom()
}

No trecho final do código, é feita a inicialização do emulador CHIP-8, a partir da criação de uma instância da função construtora chip8. Em seguida, uma ROM é carregada através do XMLHttpRequest. Depois guardamos os aquivos da rom em um array de 8 bits. Por fim, chamamos o método loadRom do chip8 passando a nossa rom.

Executando o Emulador

Para executar o emulador de CHIP-8, você precisa seguir os seguintes passos:

  1. Certifique-se de ter um ambiente de desenvolvimento web configurado, com um navegador compatível com HTML5 e JavaScript.
  2. Crie um novo arquivo HTML e inclua o seguinte código no <body>:
<canvas id="cnv"></canvas>
<script src="chip8.js"></script>
  1. Salve o arquivo HTML com o nome que desejar, por exemplo, emulador-chip8.html.
  2. coloque o arquivo chip8.js, que contém o código-fonte do emulador CHIP-8, e salve-o na mesma pasta do arquivo HTML.
  3. Abra o arquivo HTML no seu navegador.
  4. O emulador CHIP-8 será carregado e começará a executar a ROM especificada no código, conforme o trecho mencionado anteriormente.
  5. A tela do emulador será exibida no elemento <canvas>, identificado pelo ID "cnv".

Conclusão

Nesse artigo eu quis mostrar como eu construi um emulador simples de chip8. Para que não ficasse muito extenso eu apenas coloquei o básico aqui. Quem gostar do projeto e quiser se aprofundar nele pode criar as outras funções do chip8, adicionar os inputs do teclado, som e o restante das instruções.

Bibliografia