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
go mod init chip8-go

Isso gera o go.mod, onde declaro o nome do módulo e a versão do Go:

1
2
3
module chip8-go

go 1.22

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
chip8-go/
├── cmd/chip8/main.go      # entry point:  a ROM e roda o emulador
├── internal/chip8/
   ├── cpu.go             # struct CPU + o ciclo da máquina
   ├── font.go            # a fonte hexadecimal
   └── cpu_test.go        # testes unitários
├── testdata/roms/         # ROMs de teste (.ch8)
└── go.mod

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
const (
 memorySize    = 4096
 numRegisters  = 16
 stackSize     = 16
 displayWidth  = 64
 displayHeight = 32

 // ROMs sempre começam em 0x200. Os primeiros 512 bytes eram do
 // interpretador original; hoje usamos esse espaço só para a fonte.
 romStartAddress = 0x200
)

E a struct:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
type CPU struct {
 memory [memorySize]byte

 // V0-VF: 16 registradores de 8 bits. VF é usado como flag
 // (carry, borrow, colisão de sprite) por várias instruções.
 registers [numRegisters]byte

 index uint16 // I: aponta para endereços de memória
 pc    uint16 // program counter: a instrução atual

 stack [stackSize]uint16 // endereços de retorno de sub-rotinas
 sp    byte              // stack pointer: topo da stack

 delayTimer byte
 soundTimer byte

 display [displayWidth * displayHeight]byte
 keypad  [16]bool
}

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
func New() *CPU {
 cpu := &CPU{
  pc: romStartAddress,
 }
 cpu.loadFont()
 return cpu
}

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
byte    binário    pixels
0xF0    11110000   ████
0x90    10010000   █  █
0x90    10010000   █  █
0x90    10010000   █  █
0xF0    11110000   ████

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
dígito  bytes
0       F0 90 90 90 F0
1       20 60 20 20 70
2       F0 10 F0 80 F0
3       F0 10 F0 10 F0
4       90 90 F0 10 10
5       F0 80 F0 10 F0
6       F0 80 F0 90 F0
7       F0 10 20 40 40
8       F0 90 F0 90 F0
9       F0 90 F0 10 F0
A       F0 90 F0 90 90
B       E0 90 E0 90 E0
C       F0 80 80 80 F0
D       E0 90 90 90 E0
E       F0 80 F0 80 F0
F       F0 80 F0 80 80

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
var fontset = [80]byte{
 0xF0, 0x90, 0x90, 0x90, 0xF0, // 0
 0x20, 0x60, 0x20, 0x20, 0x70, // 1
 0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2
 // ... e assim por diante até o F
}

const fontStartAddress = 0x50

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
func (c *CPU) loadFont() {
 copy(c.memory[fontStartAddress:], fontset[:])
}

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
func (c *CPU) LoadROM(data []byte) error {
 if len(data) > len(c.memory[romStartAddress:]) {
  return errors.New("rom data larger than memory")
 }
 copy(c.memory[romStartAddress:], data)
 return nil
}

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
func main() {
 if len(os.Args) < 2 {
  fmt.Println("usage: chip8 <path-to-rom.ch8>")
  os.Exit(1)
 }

 data, err := os.ReadFile(os.Args[1])
 if err != nil {
  fmt.Printf("error reading ROM: %v\n", err)
  os.Exit(1)
 }

 fmt.Printf("ROM loaded: %d bytes\n", len(data))

 cpu := chip8.New()
 if err := cpu.LoadROM(data); err != nil {
  fmt.Printf("error loading ROM into memory: %v\n", err)
  os.Exit(1)
 }
}

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