CHIP-8: setup do projeto e a struct da CPU
No post anterior a gente passou pela especificação do CHIP-8 no papel: memória, registradores, program counter, stack, timers. Agora é hora de transformar isso em código Go. A meta deste post é montar o esqueleto do projeto, traduzir a especificação em uma struct de CPU e carregar a fonte hexadecimal na memória.
Estrutura do projeto
Começando do zero, um go mod init para criar o módulo:
1 | |
Isso gera o go.mod, onde declaro o nome do módulo e a versão do Go:
1 2 3 | |
Para o layout dos diretórios eu segui uma convenção comum em projetos Go, com dois lugares principais:
1 2 3 4 5 6 7 8 | |
A ideia é separar o ponto de entrada do programa do núcleo da emulação. O
cmd/chip8 guarda o main, que é fininho: ele só lê o arquivo da ROM e entrega
para o emulador. Toda a lógica de verdade fica em internal/chip8. O nome
internal não é decorativo, é uma regra do próprio Go: pacotes dentro de uma
pasta internal só podem ser importados por código do mesmo módulo. Como esse
core não é uma biblioteca para os outros usarem, e sim algo interno do projeto,
o lugar dele é ali.
A struct da CPU
Aqui é onde a especificação do post anterior vira código. Cada item que listamos lá (memória, registradores, program counter, stack, timers) vira um campo de uma struct que representa o estado completo da máquina. Antes da struct, algumas constantes com os tamanhos, para não ficar número mágico espalhado pelo código:
1 2 3 4 5 6 7 8 9 10 11 | |
E a struct:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
Se você comparar com a lista de especificação, é quase uma tradução linha a
linha. A memory é o array de 4 KB. registers são os 16 registradores de 8
bits, e o byte do Go é justamente um inteiro de 8 bits sem sinal, então serve
direto. O index é o registrador I, de 16 bits, por isso uint16. O pc
também é de 16 bits porque precisa endereçar qualquer posição da memória.
A stack guarda endereços de retorno, cada um de 16 bits, e o sp é só um
índice apontando para o topo dela. Os dois timers são bytes. O display é um
buffer linear de 64 vezes 32 posições, uma por pixel, e o keypad marca quais
das 16 teclas estão pressionadas. Esses dois últimos ainda não são usados agora,
mas já deixo o espaço reservado para não mexer na struct depois.
Falta um jeito de criar uma CPU num estado inicial correto. É o papel do
construtor New:
1 2 3 4 5 6 7 | |
Duas coisas acontecem aqui. O pc começa apontando para 0x200, que é onde a
ROM vai ser carregada, então a primeira instrução executada é a primeira
instrução do programa. E a fonte hexadecimal é carregada na memória logo de
cara, com o loadFont, que é o assunto da próxima seção. Todo o resto dos campos
fica no valor zero do Go, que por acaso é exatamente o estado inicial que
queremos: memória zerada, registradores zerados, timers em zero.
Carregando a fonte na memória
O CHIP-8 não tem tipografia de verdade nem uma tabela ASCII. O que ele oferece é uma fonte embutida com os 16 dígitos hexadecimais, de 0 a F, que os jogos usam para mostrar coisas como pontuação na tela. Cada dígito é um pequeno sprite e é desenhado com o mesmo opcode que desenha qualquer outra imagem, o DXYN. Por isso carregar essa fonte na memória é uma das primeiras coisas que fazemos, antes mesmo de rodar qualquer ROM.
Cada caractere ocupa 5 bytes, e cada byte representa uma linha de 8 pixels. Como os sprites da fonte têm só 4 pixels de largura, apenas os 4 bits mais significativos de cada byte são usados. O bit 1 é um pixel aceso e o bit 0 um pixel apagado. Fica mais claro olhando o dígito 0:
1 2 3 4 5 6 | |
Repare que os 4 bits da direita de cada byte estão sempre em zero. Eles não são desenhados, existem só porque a memória trabalha em bytes de 8 bits.
Os 16 dígitos formam uma tabela de 80 bytes (16 vezes 5). Esse layout é um padrão do CHIP-8, não uma escolha nossa, é assim que os jogos esperam encontrar a fonte na memória:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
Falta decidir onde na memória guardar essa tabela. Qualquer lugar dentro dos primeiros 512 bytes reservados serve, já que aquele espaço está livre. Por convenção a maioria dos emuladores de referência usa o endereço 0x50, e vamos seguir esse costume para ficar fácil comparar nosso código com o dos outros.
Em Go isso vira um array de 80 bytes, com um comentário marcando qual dígito começa em cada linha:
1 2 3 4 5 6 7 8 | |
Carregar isso na memória é uma cópia simples. O Go tem uma função copy da
biblioteca padrão que copia de um slice para outro, então não preciso de um laço:
1 2 3 | |
O c.memory[fontStartAddress:] é um slice que começa em 0x50 e vai até o fim
da memória, e o copy despeja os 80 bytes da fonte a partir dali. É esse método
que o New chama quando cria a CPU.
Carregando uma ROM
Com a fonte no lugar, falta colocar o programa na memória. Uma ROM de CHIP-8 é
só um arquivo binário com as instruções, e ela entra na memória a partir de
0x200, logo depois da região reservada. O LoadROM faz essa cópia:
1 2 3 4 5 6 7 | |
Antes de copiar tem uma checagem que é fácil de esquecer: e se a ROM for maior
que o espaço disponível? Da memória total de 4096 bytes, só sobram 3584 a partir
de 0x200. Sem essa verificação, o copy até funcionaria (ele copia só o que
cabe), mas o programa ficaria truncado silenciosamente e o bug seria bem chato
de achar depois. Prefiro devolver um erro explícito e parar ali.
Quem lê o arquivo do disco não é o core da emulação, e sim o main. Ele pega o
caminho da ROM nos argumentos, lê os bytes com os.ReadFile e passa para a CPU:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | |
Nesse ponto dá para rodar o programa apontando para uma ROM e ver o tamanho dela
impresso no terminal. Não é lá muito emocionante, a máquina ainda não executa
nada, mas o estado inteiro já está montado: memória com a fonte, ROM carregada,
registradores e timers zerados, PC apontando para 0x200. A CPU está pronta para
começar a pensar.
Próximos passos
O que falta é dar vida a essa struct. No próximo post entra o coração do emulador: a capacidade de buscar, decodificar e executar as instruções.
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.
Comentários