No post anterior a gente montou o ciclo fetch-decode-execute inteiro, mas com um detalhe importante: o Executeidentificava os opcodes. Todo case do switch era um TODO. A máquina lia o programa, decodificava cada instrução direitinho e, no fim, não fazia absolutamente nada com ela.

Agora isso muda. A meta deste post é implementar o conjunto mínimo de instruções para uma ROM de verdade rodar e desenhar algo na tela. Vamos usar a ROM IBM Logo.ch8, que é o "hello world" dos emuladores de CHIP-8. Ela não espera input, não depende de timer, não faz aritmética complicada. Ela só limpa a tela e desenha o logo da IBM. É o jeito mais direto de saber se o núcleo do emulador está de pé.

As instruções que o IBM Logo usa

Uma das vantagens de já ter o trace do post anterior é que dá para ver quais opcodes essa ROM executa. Nas primeiras batidas aparecem coisas como 0x00E0, 0xA22A, 0x600C, 0x6108, 0xD01F, 0x7009, e lá no fim da ROM ainda vem um 0x1228 que o trace não chegou a mostrar. Se você agrupar por família, sobra uma lista curta:

Opcode Nome O que faz
6XNN LD Vx, NN coloca o valor NN no registrador Vx
7XNN ADD Vx, NN soma NN ao registrador Vx
ANNN LD I, NNN aponta o índice I para o endereço NNN
1NNN JP salta para o endereço NNN
00E0 CLS limpa a tela
DXYN DRW desenha um sprite na tela

São só seis, de trinta e cinco. Vou implementar na ordem do mais simples ao mais complexo, deixando o DXYN (que desenha) por último, porque é onde mora quase toda a dificuldade.

Todas elas moram dentro do switch do Execute, no lugar dos TODO. Lembrando que os campos x, y, n, nn e nnn já vêm recortados do opcode no começo do método, como vimos no post do decode. Aqui é só usá-los.

Carregar um valor: 6XNN e 7XNN

As duas mais simples mexem só num registrador.

O 6XNN (LD Vx, NN) escreve um valor imediato num registrador. Se o opcode é 0x600C, ele coloca 0x0C no registrador V0. Em Go é uma linha:

1
2
case 0x6000: // 6XNN LD Vx, NN: Vx = NN
 c.registers[x] = byte(nn)

O único detalhe é o byte(nn). O nn foi recortado do opcode como um uint16, mas os registradores são de 8 bits (byte). A conversão byte(nn) pega só os 8 bits de baixo, que é justamente o que o nn carrega. Sem essa conversão o Go nem compila, porque não deixa você guardar um uint16 onde cabe um byte.

O 7XNN (ADD Vx, NN) é quase igual, mas soma em vez de sobrescrever:

1
2
case 0x7000: // 7XNN ADD Vx, NN: Vx += NN
 c.registers[x] += byte(nn)

Aqui aparece o primeiro conceito que vale destacar: o wraparound. Um byte guarda valores de 0 a 255. E se V0 já vale 0xFF (255) e a instrução manda somar 1? Em muitas linguagens isso seria um estouro a se preocupar. Em Go, a aritmética de inteiros sem sinal simplesmente dá a volta:

1
2
3
  0xFF + 0x01
= 255  + 1
= 0x00   (volta pra zero, não vira 256)

Isso não é um bug que eu preciso evitar, é o comportamento que o CHIP-8 espera. Os registradores dele são de 8 bits e transbordam do mesmo jeito no hardware original. O Go faz isso de graça, então não preciso de nenhuma verificação.

Apontar o índice: ANNN

O ANNN (LD I, NNN) coloca um endereço de 12 bits no registrador de índice I:

1
2
case 0xA000: // ANNN LD I, NNN: I = NNN
 c.index = nnn

Também é uma linha, mas ela prepara o terreno pro desenho. Como vimos lá no primeiro post, o I aponta para posições da memória, e o principal uso dele é dizer onde estão os bytes do sprite que a próxima instrução de desenho vai ler. Quando o IBM Logo executa 0xA22A, ele está dizendo "o próximo sprite começa no endereço 0x22A". Guardar isso é tudo que o ANNN faz.

Saltar: 1NNN

O 1NNN (JP) é o primeiro que mexe no fluxo do programa. Em vez de deixar o PC seguir para a próxima instrução, ele desvia a execução para outro endereço:

1
2
case 0x1000: // 1NNN JP: salta para NNN
 c.pc = nnn

Aqui vale reencontrar um detalhe do ciclo, do post anterior. O Cycle faz o pc += 2 antes de chamar o Execute. Então quando o 1NNN grava c.pc = nnn, ele está sobrescrevendo aquele valor já incrementado. Era exatamente por isso que a ordem importava: se o incremento acontecesse depois do execute, ele atropelaria o salto e mandaria a máquina pro endereço errado.

Esse opcode também é o que faz o IBM Logo parar. A última coisa que a ROM executa é um 0x1228, um salto para o próprio endereço onde ela está. Ela salta pra si mesma, pra sempre. Esse "salto pra si mesmo" é o jeito clássico de uma ROM de CHIP-8 sinalizar "acabei, não tem mais nada pra fazer". Vou usar isso daqui a pouco pra saber a hora de parar o loop.

Limpar a tela: 00E0

O 00E0 (CLS) é o primeiro opcode que toca o display. Ele apaga tudo:

1
2
case 0x00E0: // 00E0 CLS: limpa a tela
 c.display = [displayWidth * displayHeight]byte{}

O truque aqui é bem de Go. O display é um array de tamanho fixo, e atribuir a ele um array literal vazio ([...]byte{}) preenche cada posição com o valor zero do tipo. Uma linha zera os 2048 pixels de uma vez, sem laço nenhum. É o mesmo princípio que faz o New() já entregar a CPU com memória e registradores zerados: em Go, o "vazio" de um tipo numérico é o zero.

Desenhar: DXYN

Chegamos ao opcode que carrega o post nas costas. O DXYN (DRW Vx, Vy, N) desenha um sprite na tela, e é onde quase toda a complexidade do IBM Logo mora. Ele junta várias ideias, então vou por partes antes de mostrar o código inteiro.

O que é um sprite

Um sprite no CHIP-8 é uma imagenzinha guardada na memória como uma sequência de bytes. Cada byte é uma linha, e cada bit desse byte é um pixel: bit 1 aceso, bit 0 apagado. Como o byte tem 8 bits, todo sprite tem exatamente 8 pixels de largura. A altura é o N do opcode: um DXYN com N = 5 desenha 5 linhas, ou seja, lê 5 bytes.

E onde estão esses bytes? A partir do endereço que está no I, que é justamente o que o ANNN acabou de configurar. É a mesma mecânica da fonte hexadecimal que carreguei na memória no segundo post. Aliás, dá pra reusar o dígito 0 da fonte como exemplo. Ele é formado por estes cinco bytes:

1
2
3
4
5
6
byte    binário    pixels
0xF0    11110000   ████
0x90    10010000   █  █
0x90    10010000   █  █
0x90    10010000   █  █
0xF0    11110000   ████

Os quatro bits da direita ficam sempre em zero porque os dígitos da fonte só usam 4 pixels de largura, mas a mecânica é essa: o desenho é o próprio conteúdo binário dos bytes.

Lendo os pixels de uma linha

Para desenhar uma linha, eu preciso olhar cada um dos 8 bits do byte, isoladamente, da esquerda pra direita. Isso é bit a bit, com a mesma dupla máscara-e-shift que apareceu no post do decode. Para o bit da coluna col:

1
(spriteByte >> (7 - col)) & 1

O shift traz o bit que me interessa para a posição mais à direita, e o & 1 descarta todo o resto, sobrando só aquele bit. Vendo aplicado no 0x90 da fonte, coluna por coluna:

1
2
3
4
5
6
7
8
spriteByte = 0x90 = 1 0 0 1 0 0 0 0
             col =  0 1 2 3 4 5 6 7

col 0:  0x90 >> 7 = 0000 0001, & 1 = 1   (aceso)
col 1:  0x90 >> 6 = 0000 0010, & 1 = 0   (apagado)
col 2:  0x90 >> 5 = 0000 0100, & 1 = 0   (apagado)
col 3:  0x90 >> 4 = 0000 1001, & 1 = 1   (aceso)
...

Repare que no col 0 uso shift de 7 (7 - 0) para o bit mais à esquerda, e vou diminuindo. É assim que percorro o byte da esquerda pra direita mesmo lendo os bits de baixo pra cima.

Desenho por XOR e a flag de colisão

Achado o pixel do sprite, falta combiná-lo com o que já está na tela. E aqui o CHIP-8 faz uma escolha específica: o desenho é por XOR. O pixel novo não sobrescreve o antigo, ele inverte:

1
2
3
4
5
tela ^ sprite = resultado
 0      0         0
 0      1         1   (acende)
 1      0         1   (continua aceso)
 1      1         0   (apaga!)

O caso interessante é o último. Desenhar um pixel aceso em cima de outro pixel já aceso apaga os dois. É esse comportamento que permite, por exemplo, apagar um sprite redesenhando ele no mesmo lugar.

E é aí que entra o VF, aquele registrador que lá no primeiro post eu disse que serve de flag. Quando um desenho apaga algum pixel que estava aceso (uma colisão), o DXYN liga o VF = 1. Se nada foi apagado, VF = 0. Os jogos usam essa flag pra detectar coisas se sobrepondo na tela, tipo a bolinha do Pong batendo na raquete. No IBM Logo isso não importa muito, mas a instrução tem que fazer certo desde já.

Um jeito bom de fixar isso: desenhar o mesmo sprite duas vezes no mesmo lugar limpa a tela, porque o XOR consigo mesmo se cancela, e o segundo desenho liga o VF, porque apagou pixels que o primeiro tinha acendido.

O código montado

Com as três ideias na cabeça (sprite na memória a partir do I, ler bit a bit, desenhar por XOR marcando colisão), o código fecha assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
case 0xD000: // DXYN DRW Vx, Vy, N: desenha sprite de N bytes em (Vx, Vy)
 c.registers[0xF] = 0
 x0 := int(c.registers[x]) % displayWidth  // posição inicial com wrap
 y0 := int(c.registers[y]) % displayHeight
 height := int(n)

 for row := 0; row < height; row++ {
  spriteByte := c.memory[int(c.index)+row]

  posY := y0 + row
  if posY >= displayHeight {
   break // passou da borda de baixo, para
  }

  for col := 0; col < 8; col++ {
   posX := x0 + col
   if posX >= displayWidth {
    break // passou da borda da direita, para
   }

   bit := (spriteByte >> (7 - col)) & 1
   if bit == 1 {
    i := posY*displayWidth + posX

    if c.display[i] == 1 {
     c.registers[0xF] = 1 // apagou um pixel aceso: colisão
    }

    c.display[i] ^= 1
   }
  }
 }

Dois detalhes que ainda não comentei. O primeiro é o wrap da posição inicial: Vx % 64 e Vy % 32. Se um sprite for desenhado numa coordenada maior que a tela, ela dá a volta e reaparece do outro lado. O segundo é o mapeamento do par (posX, posY) para o buffer, que é linear: o índice de um pixel na linha posY, coluna posX, é posY * largura + posX. É a conta que transforma uma coordenada 2D num único índice do array. Os break nas bordas evitam que um sprite que começa perto da borda vaze e escreva na linha de baixo.

Fazendo a máquina andar

Com os seis opcodes no lugar, falta só rodar o ciclo até o fim e mostrar o resultado. Isso vive no main, que não faz parte do núcleo da emulação.

O loop de execução roda Cycle várias vezes. Mas quando parar? Lembra do auto-salto: quando a ROM salta pra si mesma, o PC deixa de mudar. Então basta guardar o PC antes de cada ciclo e comparar depois. Se não mudou, a máquina "parou" e não tem mais nada pra acontecer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const maxCycles = 1000

for i := 0; i < maxCycles; i++ {
 pcBefore := cpu.PC()
 cpu.Cycle()
 if cpu.PC() == pcBefore { // saltou pra si mesma: nada mais muda
  break
 }
}

render(cpu.Display())

O maxCycles é uma rede de segurança. Se por algum motivo a ROM nunca cair nesse auto-salto (um bug meu, uma ROM estranha), o teto evita que o programa fique girando pra sempre.

No fim, o render transforma o buffer de pixels em texto. Ele percorre as 32 linhas por 64 colunas e escreve um # pra pixel aceso e um espaço pra apagado:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func render(display [chip8.DisplayWidth * chip8.DisplayHeight]byte) {
 var sb strings.Builder
 for y := 0; y < chip8.DisplayHeight; y++ {
  for x := 0; x < chip8.DisplayWidth; x++ {
   if display[y*chip8.DisplayWidth+x] == 1 {
    sb.WriteByte('#')
   } else {
    sb.WriteByte(' ')
   }
  }
  sb.WriteByte('\n')
 }
 fmt.Print(sb.String())
}

Reparou no strings.Builder? Ele é o jeito idiomático de Go de montar uma string grande aos pedaços. Concatenar com += dentro de um laço criaria uma string nova a cada volta (strings em Go são imutáveis), o que é desperdício. O Builder acumula os bytes num buffer só e entrega a string pronta no final. E de novo aparece a conta y * largura + x pra achar o pixel: a mesma que o DXYN usa pra escrever, agora usada pra ler.

O logo na tela

Hora da verdade. Compilando e apontando pro IBM Logo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ go run ./cmd/chip8 "testdata/roms/IBM Logo.ch8"
ROM loaded: 132 bytes

            ######## #########   #####         #####
            ######## ########### ######       ######
              ####     ###   ###   #####     #####
              ####     #######     ####### #######
              ####     #######     ### ####### ###
              ####     ###   ###   ###  #####  ###
            ######## ########### #####   ###   #####
            ######## #########   #####    #    #####

Depois de três posts montando estado que não fazia nada, a máquina finalmente mostra alguma coisa. São 132 bytes de ROM, seis opcodes implementados, e o logo da IBM desenhado a partir dos sprites que estavam guardados na própria ROM.

Vale ser honesto sobre o tamanho do passo: são seis de trinta e cinco opcodes. O IBM Logo é generoso porque não usa quase nada da máquina. Não tem aritmética entre registradores, nem saltos condicionais, nem sub-rotinas, nem input, nem timers. Pra rodar um jogo de verdade falta bastante coisa. Mas o esqueleto está provado: fetch, decode, execute, e agora um execute que muda o estado e chega até a tela.

Próximos passos

No próximo post a gente continua preenchendo o resto do switch, implementando as outras instruções que faltam.

Até lá.

Referências