CHIP-8: as primeiras instruções
No post anterior a gente
montou o ciclo fetch-decode-execute inteiro, mas com um detalhe importante: o
Execute só identificava 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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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
- Acompanhe a evolução no repositório Git do projeto.
- Este commit é o estado do código no momento da escrita do texto.
- Guide to making a CHIP-8 emulator
- Cowgod's Chip-8 Technical Reference v1.0
Comentários