Antes de mergulhar neste artigo, gostaria de fornecer uma rápida introdução ao que são emuladores. Nos termos mais simples, um emulador é um software que permite que um sistema se comporte como outro sistema.

Atualmente, um uso muito popular para emuladores é emular sistemas antigos de videogame, como o Nintendo 64, Gamecube e assim por diante.

Por exemplo, com um emulador de Nintendo 64, podemos executar jogos de Nintendo 64 diretamente em um computador com Windows 10, sem a necessidade do console real. No nosso caso, estamos emulando o Chip-8 em nosso sistema host através do uso do emulador que criaremos neste artigo.

Uma das maneiras mais simples de aprender a criar seus próprios emuladores é começar com um emulador de Chip-8. Com apenas 4KB de memória e 36 instruções, você pode começar a operar com seu próprio emulador Chip-8 em menos de um dia. Você também obterá o conhecimento necessário para passar para emuladores maiores e mais detalhados.

Este será um artigo muito profundo e longo, na esperança de entender tudo. Ter um entendimento básico de operações hexadecimais, binárias e bit a bit seria benéfico.

Cada seção é dividida pelo arquivo em que estamos trabalhando e novamente pela função em que estamos trabalhando, para facilitar o acompanhamento. Quando terminarmos cada arquivo, fornecerei um link para o código completo, com comentários.

Para todo este artigo, faremos referência ao Referência técnica Chip-8 por Cowgod, que explica todos os detalhes do Chip-8.

Você pode usar o idioma que desejar para criar o emulador, embora este artigo esteja usando JavaScript. Eu sinto que é o idioma mais simples a ser usado na criação de emuladores pela primeira vez, considerando que fornece suporte para renderização, teclado e som imediatamente.

O mais importante é que você entenda o processo de emulação; portanto, use qualquer linguagem com a qual se sinta mais confortável.

Se você decidir usar JavaScript, precisará executar um servidor da Web local para teste. Eu uso o Python para isso, que permite iniciar um servidor web na pasta atual executando python3 -m http.server.

Vamos começar criando o index.html e style.css arquivos, depois vá para o renderizador, teclado, alto-falante e, finalmente, a CPU real. Nossa estrutura de projeto ficará assim:

- roms- scripts    chip8.js    cpu.js    keyboard.js    renderer.js    speaker.jsindex.htmlstyle.css

Índice e estilos

Não há nada louco por esses dois arquivos, eles são muito básicos. o index.html O arquivo simplesmente carrega nos estilos, cria um elemento de tela e carrega o chip8.js Arquivo.

                                        

o style.css O arquivo é ainda mais simples, pois a única coisa que está sendo estilizada é a tela para facilitar a localização.

canvas {    border: 2px solid black;}

Você não precisará tocar nesses dois arquivos novamente ao longo deste artigo, mas sinta-se à vontade para estilizar a página da maneira que desejar.

renderer.js

Nosso renderizador irá lidar com tudo relacionado a gráficos. Inicializará nosso elemento de tela, alternará os pixels em nossa exibição e renderizará esses pixels em nossa tela.

class Renderer {}export default Renderer;

construtor (escala)

A primeira ordem do dia é construir nosso renderizador. Esse construtor aceitará um único argumento, scale, o que nos permitirá dimensionar a exibição para cima ou para baixo, tornando os pixels maiores ou menores.

class Renderer {    constructor(scale) {    }}export default Renderer;

Precisamos inicializar algumas coisas nesse construtor. Primeiro, o tamanho da tela, que para o Chip-8 é de 64×32 pixels.

this.cols = 64;this.rows = 32;

Em um sistema moderno, isso é incrivelmente pequeno e difícil de entender, e é por isso que queremos ampliar a exibição para torná-la mais fácil de usar. Permanecendo dentro do nosso construtor, queremos definir a escala, pegar a tela, obter o contexto e definir a largura e a altura da tela.

this.scale = scale;this.canvas = document.querySelector('canvas');this.ctx = this.canvas.getContext('2d');this.canvas.width = this.cols * this.scale;this.canvas.height = this.rows * this.scale;

Como você pode ver, estamos usando o scale variável para aumentar a largura e a altura da nossa tela. Nós estaremos usando scale novamente quando começamos a renderizar os pixels na tela.

O último item que precisamos adicionar ao nosso construtor é uma matriz que atuará como nossa exibição. Como uma tela Chip-8 tem 64×32 pixels, o tamanho da nossa matriz é simplesmente 64 * 32 (colunas * linhas) ou 2048. Basicamente, estamos representando cada pixel, ativado (1) ou desativado (0), em um Chip-8 com esta matriz.

this.display = new Array(this.cols * this.rows);

Mais tarde, isso será usado para renderizar pixels dentro de nossa tela nos lugares corretos.

setPixel (x, y)

Sempre que nosso emulador ativar ou desativar um pixel, a matriz de exibição será modificada para representá-lo.

Falando em ativar ou desativar os pixels, vamos criar a função responsável por isso. Vamos chamar a função setPixel e vai demorar um x e y posição como parâmetros.

setPixel(x, y) {}

De acordo com a referência técnica, se um pixel é posicionado fora dos limites da tela, ele deve ser inclinado para o lado oposto, por isso precisamos levar isso em consideração.

if (x > this.cols) {    x -= this.cols;} else if (x < 0) {
    x += this.cols;
}

if (y > this.rows) {    y -= this.rows;} else if (y < 0) {    y += this.rows;}

Com isso, podemos calcular corretamente a localização do pixel na tela.

let pixelLoc = x + (y * this.cols);

Se você não estiver familiarizado com operações bit a bit, esse próximo trecho de código pode ser confuso. De acordo com a referência técnica, os sprites são XORed no visor:

this.display[pixelLoc] ^= 1;

Tudo o que esta linha está fazendo é alternar o valor em pixelLoc (0 a 1 ou 1 a 0). Um valor de 1 significa que um pixel deve ser desenhado, um valor de 0 significa que um pixel deve ser apagado. A partir daqui, basta retornar um valor para indicar se um pixel foi apagado ou não.

Esta parte, em particular, é importante mais tarde, quando chegarmos à CPU e escrever as diferentes instruções.

return !this.display[pixelLoc];

Se isso retornar verdadeiro, um pixel foi apagado. Se isso retornar falso, nada foi apagado. Quando chegarmos à instrução que utiliza essa função, fará mais sentido.

Claro()

Esta função limpa completamente nossa display matriz reinicializando-o.

clear() {    this.display = new Array(this.cols * this.rows);}

render ()

o render A função é responsável por renderizar os pixels no display matriz na tela. Para este projeto, ele será executado 60 vezes por segundo.

render() {    // Clears the display every render cycle. Typical for a render loop.    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);    // Loop through our display array    for (let i = 0; i < this.cols * this.rows; i++) {        // Grabs the x position of the pixel based off of `i`        let x = (i % this.cols) * this.scale;        // Grabs the y position of the pixel based off of `i`        let y = Math.floor(i / this.cols) * this.scale;        // If the value at this.display[i] == 1, then draw a pixel.        if (this.display[i]) {            // Set the pixel color to black            this.ctx.fillStyle="#000";            // Place a pixel at position (x, y) with a width and height of scale            this.ctx.fillRect(x, y, this.scale, this.scale);        }    }}

testRender ()

Para fins de teste, vamos criar uma função que desenhe alguns pixels na tela.

testRender() {    this.setPixel(0, 0);    this.setPixel(5, 2);}

Código renderer.js completo

chip8.js

Agora que temos nosso renderizador, precisamos inicializá-lo dentro do nosso chip8.js Arquivo.

import Renderer from './renderer.js';const renderer = new Renderer(10);

A partir daqui, precisamos criar um loop que seja executado, de acordo com a referência técnica, 60hz ou 60 quadros por segundo. Assim como nossa função de renderização, isso não é específico do Chip-8 e pode ser modificado um pouco para funcionar com praticamente qualquer outro projeto.

let loop;let fps = 60, fpsInterval, startTime, now, then, elapsed;function init() {    fpsInterval = 1000 / fps;    then = Date.now();    startTime = then;    // TESTING CODE. REMOVE WHEN DONE TESTING.    renderer.testRender();    renderer.render();    // END TESTING CODE    loop = requestAnimationFrame(step);}function step() {    now = Date.now();    elapsed = now - then;    if (elapsed > fpsInterval) {        // Cycle the CPU. We'll come back to this later and fill it out.    }    loop = requestAnimationFrame(step);}init();

Se você iniciar o servidor da Web e carregar a página em um navegador da Web, verá dois pixels desenhados na tela. Se quiser, brinque com a balança e encontre algo que funcione melhor para você.

keyboard.js

Referência do teclado

A referência técnica nos diz que o Chip-8 usa um teclado hexadecimal de 16 teclas, conforme descrito a seguir:

1 123C
456D
789E
UMA0 0BF

Para fazer isso funcionar em sistemas modernos, precisamos mapear uma tecla do teclado para cada uma dessas teclas do Chip-8. Faremos isso dentro do nosso construtor, além de algumas outras coisas.

construtor()

class Keyboard {    constructor() {        this.KEYMAP = {            49: 0x1, // 1            50: 0x2, // 2            51: 0x3, // 3            52: 0xc, // 4            81: 0x4, // Q            87: 0x5, // W            69: 0x6, // E            82: 0xD, // R            65: 0x7, // A            83: 0x8, // S            68: 0x9, // D            70: 0xE, // F            90: 0xA, // Z            88: 0x0, // X            67: 0xB, // C            86: 0xF  // V        }        this.keysPressed = [];        // Some Chip-8 instructions require waiting for the next keypress. We initialize this function elsewhere when needed.        this.onNextKeyPress = null;        window.addEventListener('keydown', this.onKeyDown.bind(this), false);        window.addEventListener('keyup', this.onKeyUp.bind(this), false);    }}export default Keyboard;

Dentro do construtor, criamos um mapa de teclas que está mapeando as teclas do nosso teclado para as teclas do teclado Chip-8. Além disso, temos uma matriz para acompanhar as teclas pressionadas, uma variável nula (sobre a qual falaremos mais adiante) e alguns ouvintes de eventos para lidar com a entrada do teclado.

isKeyPressed (keyCode)

Precisamos de uma maneira de verificar se uma certa tecla é pressionada. Isso simplesmente verificará o keysPressed matriz para o Chip-8 especificado keyCode.

isKeyPressed(keyCode) {    return this.keysPressed[keyCode];}

onKeyDown (evento)

Em nosso construtor, adicionamos um keydown ouvinte de eventos que chamará essa função quando acionado.

onKeyDown(event) {    let key = this.KEYMAP[event.which];    this.keysPressed[key] = true;    // Make sure onNextKeyPress is initialized and the pressed key is actually mapped to a Chip-8 key    if (this.onNextKeyPress !== null && key) {        this.onNextKeyPress(parseInt(key));        this.onNextKeyPress = null;    }}

Tudo o que estamos fazendo aqui é adicionar a tecla pressionada ao nosso keysPressed matriz e em execução onNextKeyPress se foi inicializado e uma tecla válida foi pressionada.

Vamos falar sobre isso se declaração. Uma das instruções do Chip-8 (Fx0A) aguarda um pressionamento de tecla antes de continuar a execução. Nós vamos fazer o Fx0A instrução inicialize o onNextKeyPress , que nos permitirá imitar esse comportamento de esperar até o próximo pressionamento de tecla. Depois que escrevermos esta instrução, explicarei isso com mais detalhes, pois deve fazer mais sentido quando você a vir.

onKeyUp (evento)

Também temos um ouvinte de eventos para lidar com keyup eventos, e essa função será chamada quando esse evento for acionado.

onKeyUp(event) {    let key = this.KEYMAP[event.which];    this.keysPressed[key] = false;}

Código keyboard.js completo

chip8.js

Com a classe de teclado criada, podemos voltar para chip8.js e conecte o teclado.

import Renderer from './renderer.js';import Keyboard from './keyboard.js'; // NEWconst renderer = new Renderer(10);const keyboard = new Keyboard(); // NEW

speaker.js

Vamos fazer alguns sons agora. Este arquivo é bastante direto e envolve a criação de um som simples e a inicialização / parada.

construtor

class Speaker {    constructor() {        const AudioContext = window.AudioContext || window.webkitAudioContext;        this.audioCtx = new AudioContext();        // Create a gain, which will allow us to control the volume        this.gain = this.audioCtx.createGain();        this.finish = this.audioCtx.destination;        // Connect the gain to the audio context        this.gain.connect(this.finish);    }}export default Speaker;

Tudo o que estamos fazendo aqui é criar um AudioContext e conectando um ganho para que possamos controlar o volume. Não adicionarei controle de volume neste tutorial, mas se você quiser adicioná-lo, basta usar o seguinte:

// Mute the audiothis.gain.setValueAtTime(0, this.audioCtx.currentTime);
// Unmute the audiothis.gain.setValueAtTime(1, this.audioCtx.currentTime);

reprodução (frequência)

Esta função faz exatamente o que o nome sugere: reproduz um som na frequência desejada.

play(frequency) {    if (this.audioCtx && !this.oscillator) {        this.oscillator = this.audioCtx.createOscillator();        // Set the frequency        this.oscillator.frequency.setValueAtTime(frequency || 440, this.audioCtx.currentTime);        // Square wave        this.oscillator.type="square";        // Connect the gain and start the sound        this.oscillator.connect(this.gain);        this.oscillator.start();    }}

Estamos criando um oscilador que é o que tocará nosso som. Definimos sua frequência, o tipo, conectamos ao ganho e, finalmente, tocamos o som. Nada muito louco aqui.

Pare()

Eventualmente, precisamos parar o som para que ele não toque constantemente.

stop() {    if (this.oscillator) {        this.oscillator.stop();        this.oscillator.disconnect();        this.oscillator = null;    }}

Tudo o que está fazendo é parar o som, desconectá-lo e configurá-lo como nulo para que ele possa ser reinicializado em play().

Código speaker.js completo

chip8.js

Agora podemos conectar o alto-falante ao nosso principal chip8.js Arquivo.

import Renderer from './renderer.js';import Keyboard from './keyboard.js';import Speaker from './speaker.js'; // NEWconst renderer = new Renderer(10);const keyboard = new Keyboard();const speaker = new Speaker(); // NEW

cpu.js

Agora estamos entrando no emulador de Chip-8 real. É aqui que as coisas ficam um pouco loucas, mas farei o possível para explicar tudo de uma maneira que, com sorte, faça sentido.

construtor (renderizador, teclado, alto-falante)

Precisamos inicializar algumas variáveis ​​específicas do Chip-8 em nosso construtor, além de algumas outras variáveis. Nós vamos estar olhando seção 2 da referência técnica para descobrir as especificações do nosso emulador Chip-8.

Aqui estão as especificações para o Chip-8:

  • 4KB (4096 bytes) de memória
  • 16 registradores de 8 bits
  • Um registro de 16 bits (this.i) para armazenar endereços de memória
  • Dois temporizadores. Um para o atraso e outro para o som.
  • Um contador de programa que armazena o endereço que está sendo executado no momento
  • Uma matriz para representar a pilha

Também temos uma variável que armazena se o emulador está em pausa ou não, e a velocidade de execução do emulador.

class CPU {    constructor(renderer, keyboard, speaker) {        this.renderer = renderer;        this.keyboard = keyboard;        this.speaker = speaker;        // 4KB (4096 bytes) of memory        this.memory = new Uint8Array(4096);        // 16 8-bit registers        this.v = new Uint8Array(16);        // Stores memory addresses. Set this to 0 since we aren't storing anything at initialization.        this.i = 0;        // Timers        this.delayTimer = 0;        this.soundTimer = 0;        // Program counter. Stores the currently executing address.        this.pc = 0x200;        // Don't initialize this with a size in order to avoid empty results.        this.stack = new Array();        // Some instructions require pausing, such as Fx0A.        this.paused = false;        this.speed = 10;    }}export default CPU;

loadSpritesIntoMemory ()

Para esta função, estaremos referenciando seção 2.4 da referência técnica.

O Chip-8 utiliza sprites de 16, 5 bytes. Esses sprites são simplesmente os dígitos hexadecimais de 0 a F. Você pode ver todos os sprites, com seus valores binários e hexadecimais, na seção 2.4.

Em nosso código, simplesmente armazenamos os valores hexadecimais dos sprites que a referência técnica fornece em uma matriz. Se você não quiser digitar todos manualmente, copie e cole a matriz no seu projeto.

A referência afirma que esses sprites são armazenados na seção intérprete da memória (0x000 a 0x1FFF). Vamos em frente e veja o código dessa função para ver como isso é feito.

loadSpritesIntoMemory() {    // Array of hex values for each sprite. Each sprite is 5 bytes.    // The technical reference provides us with each one of these values.    const sprites = [        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    ];    // According to the technical reference, sprites are stored in the interpreter section of memory starting at hex 0x000    for (let i = 0; i < sprites.length; i++) {        this.memory[i] = sprites[i];    }}

Tudo o que fizemos foi percorrer cada byte no sprites array e armazenado na memória, começando em hexadecimal 0x000.

loadProgramIntoMemory (programa)

Para rodar ROMs, precisamos carregá-las na memória. Isso é muito mais fácil do que pode parecer. Tudo o que precisamos fazer é percorrer o conteúdo da ROM / programa e armazená-lo na memória. A referência técnica especificamente diga-nos que "a maioria dos programas do Chip-8 começa no local 0x200". Então, quando carregamos a ROM na memória, começamos em 0x200 e incremento a partir daí.

loadProgramIntoMemory(program) {    for (let loc = 0; loc < program.length; loc++) {        this.memory[0x200 + loc] = program[loc];    }}

loadRom (romName)

Agora temos uma maneira de carregar a ROM na memória, mas precisamos primeiro pegar a ROM do sistema de arquivos antes que ela possa ser carregada na memória. Para que isso funcione, você precisa ter uma ROM. Eu incluí alguns no Repo GitHub para você baixar e colocar no roms pasta do seu projeto.

O JavaScript fornece uma maneira de fazer uma solicitação HTTP e recuperar um arquivo. Adicionei comentários ao código abaixo para explicar o que está acontecendo:

loadRom(romName) {    var request = new XMLHttpRequest;    var self = this;    // Handles the response received from sending (request.send()) our request    request.onload = function() {        // If the request response has content        if (request.response) {            // Store the contents of the response in an 8-bit array            let program = new Uint8Array(request.response);            // Load the ROM/program into memory            self.loadProgramIntoMemory(program);        }    }    // Initialize a GET request to retrieve the ROM from our roms folder    request.open('GET', 'roms/' + romName);    request.responseType="arraybuffer";    // Send the GET request    request.send();}

A partir daqui, podemos iniciar o ciclo da CPU, que manipulará a execução das instruções, além de algumas outras coisas.

ciclo()

Eu acho que será mais fácil entender tudo se você puder ver o que acontece toda vez que a CPU alterna. Essa é a função que chamaremos em nosso step função em chip8.js, que, se você se lembra, é executado cerca de 60 vezes por segundo. Nós vamos pegar essa função peça por peça.

Nesse ponto, as funções chamadas dentro do cycle ainda não foram criados. Vamos criá-los em breve.

O primeiro pedaço de código dentro de nossa cycle A função é um loop for que lida com a execução de instruções. É aqui que nossa speed variável entra em jogo. Quanto maior esse valor, mais instruções serão executadas a cada ciclo.

cycle() {    for (let i = 0; i < this.speed; i++) {    }}

Também queremos ter em mente que as instruções devem ser executadas apenas quando o emulador estiver sendo executado.

cycle() {    for (let i = 0; i < this.speed; i++) {        if (!this.paused) {        }    }}

Se você der uma olhada seção 3.1, você pode ver todas as instruções diferentes e seus códigos de operação. Eles se parecem com algo 00E0 ou 9xy0 para dar alguns exemplos. Portanto, nosso trabalho é pegar esse código operacional da memória e repassá-lo para outra função que lida com a execução dessa instrução. Vamos dar uma olhada no código primeiro e depois explicarei:

cycle() {    for (let i = 0; i < this.speed; i++) {        if (!this.paused) {            let opcode = (this.memory[this.pc] << 8 | this.memory[this.pc + 1]);            this.executeInstruction(opcode);        }    }}

Vamos dar uma olhada nesta linha em particular: let opcode = (this.memory[this.pc] << 8 | this.memory[this.pc + 1]);. Para aqueles que não estão muito familiarizados com operações bit a bit, isso pode ser muito intimidador.

Antes de tudo, cada instrução tem 16 bits (2 bytes) de comprimento (3.0), mas nossa memória é composta de partes de 8 bits (1 byte). Isso significa que precisamos combinar duas partes de memória para obter o código de operação completo. É por isso que temos this.pc e this.pc + 1 na linha de código acima. Estamos simplesmente pegando as duas metades do código de operação.

Mas você não pode simplesmente combinar dois valores de 1 byte para obter um valor de 2 bytes. Para fazer isso corretamente, precisamos mudar o primeiro pedaço de memória, this.memory[this.pc], Restam 8 bits para torná-lo com 2 bytes. Nos termos mais básicos, isso adicionará dois zeros ou um valor hexadecimal mais preciso 0x00 no lado direito do nosso valor de 1 byte, tornando-o 2 bytes.

Por exemplo, deslocando hex 0x11 8 bits restantes nos dará hex 0x1100. A partir daí, OR bit a bit (|) com o segundo pedaço de memória, this.memory[this.pc + 1]).

Aqui está um exemplo passo a passo que o ajudará a entender melhor o que isso tudo significa.

Vamos assumir alguns valores, cada tamanho de 1 byte:

this.memory[this.pc] = PC = 0x10
this.memory[this.pc + 1] = PC + 1 = 0xF0

Mudança PC Restam 8 bits (1 byte) para criar 2 bytes:

PC = 0x1000

OR bit a bit PC e PC + 1:

PC | PC + 1 = 0x10F0

ou

0x1000 | 0xF0 = 0x10F0

Por fim, queremos atualizar nossos cronômetros quando o emulador estiver em execução (não pausado), reproduzir sons e renderizar sprites na tela:

cycle() {    for (let i = 0; i < this.speed; i++) {        if (!this.paused) {            let opcode = (this.memory[this.pc] << 8 | this.memory[this.pc + 1]);            this.executeInstruction(opcode);        }    }    if (!this.paused) {        this.updateTimers();    }    this.playSound();    this.renderer.render();}

Esta função é o cérebro do nosso emulador de certa forma. Ele lida com a execução de instruções, atualiza os cronômetros, reproduz som e renderiza o conteúdo na tela.

Ainda não temos nenhuma dessas funções criadas, mas ver como a CPU percorre tudo fará com que essas funções façam muito mais sentido quando as criarmos.

updateTimers ()

Vamos passar para seção 2.5 e configure a lógica para os timers e sons.

Cada temporizador, atraso e som, diminui em 1 a uma taxa de 60Hz. Em outras palavras, a cada 60 quadros, nossos temporizadores diminuirão em 1.

updateTimers() {    if (this.delayTimer > 0) {        this.delayTimer -= 1;    }    if (this.soundTimer > 0) {        this.soundTimer -= 1;    }}

O temporizador de atraso é usado para acompanhar quando certos eventos ocorrem. Esse timer é usado apenas em duas instruções: uma para definir seu valor e outra para ler seu valor e ramificar para outra instrução se um determinado valor estiver presente.

O temporizador de som é o que controla a duração do som. Desde que o valor de this.soundTimer for maior que zero, o som continuará sendo reproduzido. Quando o cronômetro do som chega a zero, o som para. Isso nos leva à nossa próxima função, onde faremos exatamente isso.

tocar música()

Para reiterar, desde que o timer do som seja maior que zero, queremos reproduzir um som. Nós estaremos usando o play função do nosso Speaker classe que fizemos anteriormente para tocar um som com uma frequência de 440.

playSound() {    if (this.soundTimer > 0) {        this.speaker.play(440);    } else {        this.speaker.stop();    }}

executeInstruction (opcode)

Para toda esta função, estaremos referenciando seções 3.0 e 3.1 da referência técnica.

Esta é a função final que precisamos para este arquivo, e esta é longa. Temos que escrever a lógica para todas as 36 instruções do Chip-8. Felizmente, a maioria dessas instruções requer apenas algumas linhas de código.

A primeira informação a ter em atenção é que todas as instruções têm 2 bytes de comprimento. Portanto, toda vez que executamos uma instrução ou executamos essa função, precisamos incrementar o contador do programa (this.pc) por 2 para que a CPU saiba onde está a próxima instrução.

executeInstruction(opcode) {    // Increment the program counter to prepare it for the next instruction.    // Each instruction is 2 bytes long, so increment it by 2.    this.pc += 2;}

Vamos dar uma olhada nesta parte da seção 3.0 agora:

In these listings, the following variables are used:nnn or addr - A 12-bit value, the lowest 12 bits of the instructionn or nibble - A 4-bit value, the lowest 4 bits of the instructionx - A 4-bit value, the lower 4 bits of the high byte of the instructiony - A 4-bit value, the upper 4 bits of the low byte of the instructionkk or byte - An 8-bit value, the lowest 8 bits of the instruction

Para evitar a repetição de código, devemos criar variáveis ​​para o x e y valores, pois são os usados ​​por quase todas as instruções. As outras variáveis ​​listadas acima não são usadas o suficiente para garantir o cálculo de seus valores sempre.

Esses dois valores têm, cada um, 4 bits (também conhecido como meio byte ou um nibble). o x O valor está localizado nos 4 bits inferiores do byte alto e y está localizado nos 4 bits superiores do byte baixo.

Por exemplo, se tivermos uma instrução 0x5460, o byte alto seria 0x54 e o byte baixo seria 0x60. Os 4 bits inferiores, ou mordidela, do byte alto seriam 0x4 e os 4 bits superiores do byte baixo seriam 0x6. Portanto, neste exemplo, x = 0x4 e y= 0x6.

Sabendo tudo isso, vamos escrever o código que vai pegar o x e y valores.

executeInstruction(opcode) {    this.pc += 2;    // We only need the 2nd nibble, so grab the value of the 2nd nibble    // and shift it right 8 bits to get rid of everything but that 2nd nibble.    let x = (opcode & 0x0F00) >> 8;    // We only need the 3rd nibble, so grab the value of the 3rd nibble    // and shift it right 4 bits to get rid of everything but that 3rd nibble.    let y = (opcode & 0x00F0) >> 4;}

Para explicar isso, vamos mais uma vez assumir que temos uma instrução 0x5460. Se nós & (AND bit a bit) essa instrução com valor hexadecimal 0x0F00 nós vamos acabar com 0x0400. Mude esses 8 bits para a direita e terminamos com 0x04 ou 0x4. A mesma coisa com y. Nós & a instrução com valor hexadecimal 0x00F0 e pegue 0x0060. Mude esses 4 bits para a direita e terminamos com 0x006 ou 0x6.

Agora, a parte divertida, escrevendo a lógica para todas as 36 instruções. Para cada instrução, antes de escrever o código, recomendo a leitura do que essa instrução faz na referência técnica, pois você a entenderá melhor.

Vou fornecer a instrução switch vazia que você usará, já que é bastante longa.

switch (opcode & 0xF000) {    case 0x0000:        switch (opcode) {            case 0x00E0:                break;            case 0x00EE:                break;        }        break;    case 0x1000:        break;    case 0x2000:        break;    case 0x3000:        break;    case 0x4000:        break;    case 0x5000:        break;    case 0x6000:        break;    case 0x7000:        break;    case 0x8000:        switch (opcode & 0xF) {            case 0x0:                break;            case 0x1:                break;            case 0x2:                break;            case 0x3:                break;            case 0x4:                break;            case 0x5:                break;            case 0x6:                break;            case 0x7:                break;            case 0xE:                break;        }        break;    case 0x9000:        break;    case 0xA000:        break;    case 0xB000:        break;    case 0xC000:        break;    case 0xD000:        break;    case 0xE000:        switch (opcode & 0xFF) {            case 0x9E:                break;            case 0xA1:                break;        }        break;    case 0xF000:        switch (opcode & 0xFF) {            case 0x07:                break;            case 0x0A:                break;            case 0x15:                break;            case 0x18:                break;            case 0x1E:                break;            case 0x29:                break;            case 0x33:                break;            case 0x55:                break;            case 0x65:                break;        }        break;    default:        throw new Error('Unknown opcode ' + opcode);}

Como você pode ver em switch (opcode & 0xF000), estamos pegando os 4 bits superiores do byte mais significativo do código de operação. Se você der uma olhada nas diferentes instruções na referência técnica, perceberá que podemos restringir os diferentes códigos de operação com essa primeira mordidela.

0nnn - endereço do SYS

Esse código de operação pode ser ignorado.

00E0 - CLS

Limpe a tela.

case 0x00E0:    this.renderer.clear();    break;

00EE - RET

Coloque o último elemento no stack matriz e armazená-lo em this.pc. Isso nos retornará de uma sub-rotina.

case 0x00EE:    this.pc = this.stack.pop();    break;

A referência técnica afirma que esta instrução também "subtrai 1 do ponteiro da pilha". O ponteiro da pilha é usado para apontar para o nível mais alto da pilha. Mas graças ao nosso stack matriz, não precisamos nos preocupar com a localização da parte superior da pilha, pois ela é tratada pela matriz. Portanto, para o restante das instruções, se houver algo sobre o ponteiro da pilha, você poderá ignorá-lo com segurança.

1nnn - endereço JP

Defina o contador do programa para o valor armazenado em nnn.

case 0x1000:    this.pc = (opcode & 0xFFF);    break;

0xFFF agarra o valor de nnn. assim 0x1426 & 0xFFF nos dará 0x426 e depois armazenamos isso em this.pc.

2nnn - endereço de CHAMADA

Para isso, a referência técnica diz que precisamos incrementar o ponteiro da pilha para que aponte para o valor atual de this.pc. Novamente, não estamos usando um ponteiro de pilha em nosso projeto como nosso stack matriz lida com isso para nós. Então, em vez de incrementar isso, apenas pressionamos this.pc na pilha que nos dará o mesmo resultado. E assim como com o opcode 1nnn, pegamos o valor de nnn e guarde isso em this.pc.

case 0x2000:    this.stack.push(this.pc);    this.pc = (opcode & 0xFFF);    break;

3xkk - SE Vx, byte

É aqui que nossa x o valor calculado acima entra em jogo.

Esta instrução compara o valor armazenado no x registro (Vx) ao valor de kk. Observe que V significa um registro e o valor a seguir, neste caso x, é o número do registro. Se forem iguais, incrementamos o contador do programa em 2, pulando efetivamente a próxima instrução.

case 0x3000:    if (this.v[x] === (opcode & 0xFF)) {        this.pc += 2;    }    break;

o opcode & 0xFF parte da instrução if está simplesmente capturando o último byte do opcode. Isto é o kk parte do código de operação.

4xkk - SNE Vx, byte

Esta instrução é muito semelhante a 3xkk, mas pula a próxima instrução se Vx e kk NÃO são iguais.

case 0x4000:    if (this.v[x] !== (opcode & 0xFF)) {        this.pc += 2;    }    break;

5xy0 - SE Vx, Vy

Agora estamos fazendo uso de ambos x e y. Esta instrução, como as duas anteriores, pulará a próxima instrução se uma condição for atendida. No caso desta instrução, se Vx é igual a Vy pulamos a próxima instrução.

case 0x5000:    if (this.v[x] === this.v[y]) {        this.pc += 2;    }    break;

6xkk - LD Vx, byte

Esta instrução irá definir o valor de Vx para o valor de kk.

case 0x6000:    this.v[x] = (opcode & 0xFF);    break;

7xkk - ADICIONAR Vx, byte

Esta instrução adiciona kk para Vx.

case 0x7000:    this.v[x] += (opcode & 0xFF);    break;

8xy0 - LD Vx, Vy

Antes de discutir esta instrução, gostaria de explicar o que está acontecendo com switch (opcode & 0xF). Por que a chave dentro de uma chave?

O raciocínio por trás disso é que temos várias instruções diferentes que se enquadram case 0x8000:. Se você der uma olhada nessas instruções na referência técnica, notará que a última mordida de cada uma dessas instruções termina com um valor 0-7 ou E.

Temos essa opção para pegar a última mordidela e, em seguida, criar um caso para cada um lidar com isso adequadamente. Fazemos isso mais algumas vezes ao longo da instrução principal do switch.

Com isso explicado, vamos às instruções. Nada louco com este, apenas definindo o valor de Vx igual ao valor de Vy.

case 0x0:    this.v[x] = this.v[y];    break;

8xy1 - OU Vx, Vy

Conjunto Vx para o valor de Vx OR Vy.

case 0x1:    this.v[x] |= this.v[y];    break;

8xy2 - AND Vx, Vy

Conjunto Vx igual ao valor de Vx AND Vy.

case 0x2:    this.v[x] &= this.v[y];    break;

8xy3 - XOR Vx, Vy

Conjunto Vx igual ao valor de Vx XOR Vy.

case 0x3:    this.v[x] ^= this.v[y];    break;

8xy4 - ADICIONAR Vx, Vy

Esta instrução define Vx para Vx + Vy. Parece fácil, mas há um pouco mais. Se lemos a descrição para esta instrução fornecida na referência técnica, ela diz o seguinte:

Se o resultado for maior que 8 bits (ou seja,> 255), VF é definido como 1, caso contrário, 0. Apenas os 8 bits mais baixos do resultado são mantidos e armazenados em Vx.

case 0x4:    let sum = (this.v[x] += this.v[y]);    this.v[0xF] = 0;    if (sum > 0xFF) {        this.v[0xF] = 1;    }    this.v[x] = sum;    break;

Tomando essa linha por linha, primeiro adicionamos this.v[y] para this.v[x] e armazene esse valor em uma variável sum. A partir daí, estabelecemos this.v[0xF]ou VF, para 0. Fazemos isso para evitar o uso de uma instrução if-else na próxima linha. Se a soma for maior que 255 ou hexadecimal 0xFF, montamos VF para 1. Finalmente, definimos this.v[x]ou Vx, à soma.

Você pode estar se perguntando como garantir que "apenas os 8 bits mais baixos do resultado sejam mantidos e armazenados em Vx". Graças a this.v Começar um Uint8Array, qualquer valor acima de 8 bits possui automaticamente os 8 bits mais baixos, à direita, obtidos e armazenados na matriz. Portanto, não precisamos fazer nada de especial com isso.

Deixe-me fornecer um exemplo para entender melhor isso. Suponha que tentemos colocar 257 decimal no this.v array. Em binário, esse valor é 100000001, um valor de 9 bits. Quando tentamos armazenar esse valor de 9 bits na matriz, ele leva apenas os 8 bits inferiores. Isso significa binário 00000001, que é 1 em decimal, seria armazenado em this.v.

8xy5 - SUB Vx, Vy

Esta instrução subtrai Vy de Vx. Assim como o overflow é tratado na instrução anterior, temos que lidar com o underflow para este.

case 0x5:    this.v[0xF] = 0;    if (this.v[x] > this.v[y]) {        this.v[0xF] = 1;    }    this.v[x] -= this.v[y];    break;

Mais uma vez, já que estamos usando um Uint8Array, não precisamos fazer nada para lidar com o fluxo insuficiente, pois isso é resolvido por nós. Então -1 se tornará 255, -2 se tornará 254 e assim por diante.

8xy6 - SHR Vx {, Vy}

case 0x6:    this.v[0xF] = (this.v[x] & 0x1);    this.v[x] >>= 1;    break;

Está linha this.v[0xF] = (this.v[x] & 0x1); vai determinar o bit menos significativo e definir VF adequadamente.

Isso é muito mais fácil de entender se você observar sua representação binária. E se Vx, em binário, é 1001, VF será definido como 1, pois o bit menos significativo é 1. Se Vx é 1000, VF será definido como 0.

8xy7 - SUBN Vx, Vy

case 0x7:    this.v[0xF] = 0;    if (this.v[y] > this.v[x]) {        this.v[0xF] = 1;    }    this.v[x] = this.v[y] - this.v[x];    break;

Esta instrução subtrai Vx de Vy e armazena o resultado em Vx. E se Vy é maior que Vx, precisamos armazenar 1 em VF, caso contrário, armazenamos 0.

8xyE - SHL Vx {, Vy}

Esta instrução não apenas muda Vx deixou 1, mas também define VF para 0 ou 1, dependendo se uma condição for atendida.

case 0xE:    this.v[0xF] = (this.v[x] & 0x80);    this.v[x] <<= 1;    break;

A primeira linha de código, this.v[0xF] = (this.v[x] & 0x80);, está pegando o pedaço mais significativo de Vx e armazenando isso em VF. Para explicar isso, temos um registro de 8 bits, Vx, e queremos obter o bit mais significativo ou mais à esquerda. Para fazer isso, precisamos AND Vx com binário 10000000ou 0x80 em hexadecimal. Isso realizará a configuração VF para o valor adequado.

Depois disso, simplesmente multiplicamos Vx por 2, deslocando-o para a esquerda 1.

9xy0 - SNE Vx, Vy

Esta instrução simplesmente incrementa o contador do programa em 2 se Vx e Vy não são iguais.

case 0x9000:    if (this.v[x] !== this.v[y]) {        this.pc += 2;    }    break;

Annn - LD I, endereço

Defina o valor do registro i para nnn. Se o opcode for 0xA740 então (opcode & 0xFFF) retornará 0x740.

case 0xA000:    this.i = (opcode & 0xFFF);    break;

Bnnn - JP V0, endereço

Defina o contador do programa (this.pc) para nnn mais o valor do registro 0 (V0)

case 0xB000:    this.pc = (opcode & 0xFFF) + this.v[0];    break;

Cxkk - RND Vx, byte

case 0xC000:    let rand = Math.floor(Math.random() * 0xFF);    this.v[x] = rand & (opcode & 0xFF);    break;

Gere um número aleatório no intervalo de 0 a 255 e depois AND com o byte mais baixo do código de operação. Por exemplo, se o opcode for 0xB849, então (opcode & 0xFF) retornaria 0x49.

Dxyn - DRW Vx, Vy, mordidela

Este é um grande problema! Esta instrução trata do desenho e apagamento de pixels na tela. Vou fornecer todo o código e explicá-lo linha por linha.

case 0xD000:    let width = 8;    let height = (opcode & 0xF);    this.v[0xF] = 0;    for (let row = 0; row < height; row++) {
        let sprite = this.memory[this.i + row];

        for (let col = 0; col < width; col++) {
            // If the bit (sprite) is not 0, render/erase the pixel
            if ((sprite & 0x80) > 0) {                // If setPixel returns 1, which means a pixel was erased, set VF to 1                if (this.renderer.setPixel(this.v[x] + col, this.v[y] + row)) {                    this.v[0xF] = 1;                }            }            // Shift the sprite left 1. This will move the next next col/bit of the sprite into the first position.            // Ex. 10010000 << 1 will become 0010000            sprite <<= 1;        }    }    break;

Nós temos uma width variável definida como 8 porque cada sprite tem 8 pixels de largura, por isso é seguro codificar esse valor. Em seguida, definimos height para o valor da última mordidela (n) do opcode. Se o nosso opcode for 0xD235, height será definido como 5. A partir daí, definimos VF para 0, que, se necessário, será definido como 1 posteriormente se os pixels forem apagados.

Agora para os loops for. Lembre-se de que um sprite se parece com isso:

1111000010010000100100001001000011110000

Nosso código está indo linha por linha (primeiro for loop), então vai pouco a pouco ou coluna a coluna (segundo for loop) através desse sprite.

Este pedaço de código, let sprite = this.memory[this.i + row];, está capturando 8 bits de memória, ou uma única linha de um sprite, armazenada em this.i + row. A referência técnica indica que começamos no endereço armazenado em Iou this.i no nosso caso, quando lemos sprites de memória.

Dentro do nosso segundo for loop, temos um if declaração que está pegando o bit mais à esquerda e verificando se é maior que 0.

Um valor 0 indica que o sprite não possui um pixel nesse local; portanto, não precisamos nos preocupar em desenhá-lo ou apagá-lo. Se o valor for 1, passamos para outra instrução if que verifica o valor de retorno de setPixel. Vamos examinar os valores passados ​​para essa função.

Nosso setPixel chamada fica assim: this.renderer.setPixel(this.v[x] + col, this.v[y] + row). De acordo com a referência técnica, o x e y posições estão localizadas em Vx e Vy respectivamente. Adicione o col número para Vx e a row número para Vy, e você obtém a posição desejada para desenhar / apagar um pixel.

E se setPixel retorna 1, apagamos o pixel e definimos VF para 1. Se retornar 0, não fazemos nada, mantendo o valor de VF igual a 0.

Por fim, estamos deslocando o sprite para a esquerda 1 bit. Isso nos permite passar por cada parte do sprite.

Por exemplo, se sprite está atualmente definido como 10010000, se tornará 0010000 depois de ser deslocado para a esquerda. A partir daí, podemos passar por outra iteração do nosso interior for loop para determinar se deve ou não desenhar um pixel. E continuando esse processo até chegarmos ao final ou nosso sprite.

Ex9E - SKP Vx

Essa é bem simples e pula a próxima instrução se a chave armazenada em Vx é pressionado, incrementando o contador de programa em 2.

case 0x9E:    if (this.keyboard.isKeyPressed(this.v[x])) {        this.pc += 2;    }    break;

ExA1 - SKNP Vx

Isso faz o oposto da instrução anterior. Se a tecla especificada não for pressionada, pule a próxima instrução.

case 0xA1:    if (!this.keyboard.isKeyPressed(this.v[x])) {        this.pc += 2;    }    break;

Fx07 - LD Vx, DT

Outro simples. Estamos apenas definindo Vx para o valor armazenado em delayTimer.

case 0x07:    this.v[x] = this.delayTimer;    break;

Fx0A - LD Vx, K

Observando a referência técnica, esta instrução pausa o emulador até que uma tecla seja pressionada. Aqui está o código para isso:

case 0x0A:    this.paused = true;    this.keyboard.onNextKeyPress = function(key) {        this.v[x] = key;        this.paused = false;    }.bind(this);    break;

Estabelecemos primeiro paused para true para pausar o emulador. Então, se você se lembra do nosso keyboard.js arquivo onde colocamos onNextKeyPress para nulo, é aqui que o inicializamos. Com o onNextKeyPress inicializada, da próxima vez que keydown evento for acionado, o seguinte código em nosso keyboard.js O arquivo será executado:

// keyboard.jsif (this.onNextKeyPress !== null && key) {    this.onNextKeyPress(parseInt(key));    this.onNextKeyPress = null;}

A partir daí, montamos Vx para o código da tecla pressionada e, finalmente, inicie o backup do emulador definindo paused para falso.

Fx15 - LD DT, Vx

Esta instrução simplesmente define o valor do temporizador para o valor armazenado no registro Vx.

case 0x15:    this.delayTimer = this.v[x];    break;

Fx18 - LD ST, Vx

Esta instrução é muito semelhante ao Fx15, mas define o temporizador para Vx em vez do temporizador de atraso.

case 0x18:    this.soundTimer = this.v[x];    break;

Fx1E - ADICIONAR I, Vx

Adicionar Vx para I.

case 0x1E:    this.i += this.v[x];    break;

Fx29 - LD F, Vx - ADICIONAR I, Vx

Para este, estamos definindo I para a localização do sprite em Vx. É multiplicado por 5 porque cada sprite tem 5 bytes de comprimento.

case 0x29:    this.i = this.v[x] * 5;    break;

Fx33 - LD B, Vx

Esta instrução irá pegar o dígito das centenas, dezenas e um do registro Vx e armazená-los em registros I, I+1e I+2 respectivamente.

case 0x33:    // Get the hundreds digit and place it in I.    this.memory[this.i] = parseInt(this.v[x] / 100);    // Get tens digit and place it in I+1. Gets a value between 0 and 99,    // then divides by 10 to give us a value between 0 and 9.    this.memory[this.i + 1] = parseInt((this.v[x] % 100) / 10);    // Get the value of the ones (last) digit and place it in I+2.    this.memory[this.i + 2] = parseInt(this.v[x] % 10);    break;

Fx55 - LD [I], Vx

Nesta instrução, estamos percorrendo os registros V0 através Vx e armazenando seu valor na memória, começando em I.

case 0x55:    for (let registerIndex = 0; registerIndex <= x; registerIndex++) {        this.memory[this.i + registerIndex] = this.v[registerIndex];    }    break;

Fx65 - LD Vx, [I]

Agora vamos para a última instrução. Este faz o oposto de Fx55. Ele lê valores da memória começando em I e os armazena em registros V0 através Vx.

case 0x65:    for (let registerIndex = 0; registerIndex <= x; registerIndex++) {        this.v[registerIndex] = this.memory[this.i + registerIndex];    }    break;

chip8.js

Com nossa classe de CPU criada, vamos terminar nosso chip8.js arquivo carregando em uma ROM e alternando nossa CPU. Precisamos importar cpu.js e inicialize um objeto da CPU:

import Renderer from './renderer.js';import Keyboard from './keyboard.js';import Speaker from './speaker.js';import CPU from './cpu.js'; // NEWconst renderer = new Renderer(10);const keyboard = new Keyboard();const speaker = new Speaker();const cpu = new CPU(renderer, keyboard, speaker); // NEW

Nosso init a função se torna:

function init() {    fpsInterval = 1000 / fps;    then = Date.now();    startTime = then;    cpu.loadSpritesIntoMemory(); // NEW    cpu.loadRom('BLITZ'); // NEW    loop = requestAnimationFrame(step);}

Quando nosso emulador é inicializado, carregaremos os sprites na memória e carregaremos o BLITZ ROM. Agora só precisamos alternar a CPU:

function step() {    now = Date.now();    elapsed = now - then;    if (elapsed > fpsInterval) {        cpu.cycle(); // NEW    }    loop = requestAnimationFrame(step);}

Com isso feito, agora devemos ter um emulador Chip8 funcionando.

Conclusão

Comecei este projeto há um tempo atrás e fiquei fascinado por ele. A criação do emulador sempre foi algo que me interessou, mas nunca fez sentido para mim. Isso foi até que eu aprendi sobre o Chip-8 e a simplicidade em comparação com sistemas mais avançados por aí.

No momento em que terminei este emulador, sabia que tinha que compartilhá-lo com outras pessoas, fornecendo um guia detalhado e passo a passo para criar você mesmo. O conhecimento que adquiri, e espero que você tenha adquirido, sem dúvida será útil em outro lugar.

Em suma, espero que tenha gostado do artigo e aprendido alguma coisa. Eu pretendia explicar tudo em detalhes e da maneira mais simples possível.

Independentemente disso, se algo ainda estiver confundindo você ou você tiver apenas uma pergunta, sinta-se à vontade para me informar sobre Twitter ou publicar um problema no Repo GitHub como eu adoraria ajudá-lo.

Gostaria de deixar algumas idéias sobre os recursos que você pode adicionar ao seu emulador de Chip-8:

  • Controle de áudio (mudo, frequência de mudança, tipo de onda (seno, triângulo) etc.)
  • Capacidade de alterar a escala de renderização e a velocidade do emulador a partir da interface do usuário
  • Pausar e cancelar a pausa
  • Capacidade de salvar e carregar um save
  • Seleção de ROM