Criando um emulador de Game Boy em OCaml (2022)
(linoscope.github.io)- CAMLBOY é um emulador de Game Boy desenvolvido em OCaml que roda no navegador
- Foi escolhido como projeto para aprender na prática desenvolvimento de projetos de médio a grande porte e uso de recursos avançados de OCaml
- Utiliza de forma prática várias características da linguagem OCaml, como estrutura básica, abstração, GADT, functors e troca de módulos em tempo de execução
- Roda a 60 FPS no navegador e compartilha a experiência de melhoria de desempenho, análise de gargalos e otimização
- Organiza o ecossistema OCaml, a automação de testes e o impacto do desenvolvimento de emuladores na evolução das habilidades profissionais
Visão geral do projeto
- Ao longo de alguns meses, o projeto CAMLBOY foi desenvolvido para criar um emulador de Game Boy em OCaml
- Pode ser executado na página de demonstração e inclui várias ROMs homebrew
- O repositório está disponível no GitHub
Motivação para aprender OCaml e contexto da escolha do projeto
- Ao aprender uma nova linguagem, havia uma sensação de limitação em relação a como escrever código de médio/grande porte e como usar recursos avançados na prática
- Para resolver esse problema, surgiu a necessidade de uma experiência concreta de projeto, levando à escolha de desenvolver um emulador de Game Boy
- Motivos
- As especificações são claras, então o escopo de implementação é bem definido
- É complexo o suficiente, mas tem um tamanho que permite conclusão em alguns meses
- Há uma forte motivação pessoal
Objetivos do emulador
- Escrever código com foco em legibilidade e manutenibilidade
- Compilar para JavaScript com js_of_ocaml para rodar no navegador
- Alcançar FPS jogável também em navegadores móveis
- Implementar benchmarks de desempenho para vários backends do compilador
Objetivo do texto e principais conteúdos
O objetivo deste texto é compartilhar a jornada de criar um emulador de Game Boy em OCaml
Conteúdos abordados:
- Visão geral da arquitetura do Game Boy
- Como estruturar código testável e altamente reutilizável
- Uso prático de recursos avançados de OCaml, como functor, GADT e módulos de primeira classe
- Como encontrar gargalos de desempenho, além de experiências com otimização e melhorias
- Reflexões gerais sobre OCaml
Estrutura geral e principais interfaces
- Os principais componentes de hardware, como CPU, Timer e GPU, operam de acordo com um clock sincronizado
- O barramento é responsável por acessar/encaminhar dados para cada módulo de hardware conforme o endereço
- Cada módulo de hardware implementa a interface
Addressable_intf.S - O barramento como um todo segue a interface
Word_addressable_intf.S
Como o loop principal funciona
- Para sincronizar o hardware, o loop principal executa o seguinte ciclo
- Executa 1 instrução da CPU e registra quantos ciclos foram consumidos
- Avança o Timer e a GPU pelo mesmo número de ciclos
- Esse método simula o estado de sincronização do hardware real
- A implementação é explicada com exemplos de código
Abstração de leitura/escrita de dados de 8 e 16 bits
- Vários módulos implementam a interface de entrada/saída de dados de 8 bits (
Addressable_intf.S) - A extensão para leitura/escrita de 16 bits herda e adiciona funcionalidades por meio de
Word_addressable_intf.S - A camada de abstração é construída usando signatures de OCaml e o mecanismo de include em tipos de módulo
Implementação do barramento, registradores e CPU
- Barramento: responsável pelo roteamento baseado em endereço para cada módulo de hardware, com desvios segundo o mapa de memória
- Registradores: oferecem interfaces de leitura/escrita para registradores de 8 e 16 bits
- CPU: no início tinha forte dependência do barramento, o que dificultava os testes
- Com a aplicação de functors, tornou-se possível abstrair dependências e injetar mocks
- Isso tornou muito mais fácil escrever testes unitários
Representação do conjunto de instruções (uso de GADT)
- O Game Boy possui instruções de 8 e 16 bits, então há necessidade de segurança de tipos na definição das instruções
- A abordagem com variant simples gera problemas de conflito de tipos de retorno em pattern matching complexo
- Ao aplicar GADT (Generalized Algebraic Data Type), torna-se possível casar com segurança tanto os tipos de entrada quanto os de saída
- Com GADT, é possível inferir corretamente os tipos dos argumentos e dos valores de retorno de cada instrução
- Isso permite lidar com segurança com padrões de instrução e parâmetros complexos
Cartuchos e seleção de módulos em tempo de execução
- Cartuchos de Game Boy podem incluir hardware adicional além de uma ROM simples, como MBC e timer
- É necessário implementar módulos separados para cada tipo e selecionar em tempo de execução o módulo adequado
- Módulos de primeira classe viabilizam a troca de módulos em tempo de execução e a extensibilidade
Testes e desenvolvimento exploratório
- Uso de test ROMs e
ppx_expect- ROMs de teste por funcionalidade: validam áreas específicas como operações aritméticas e suporte a MBC
- Em caso de falha, é possível obter diagnósticos claros, como a saída na tela
- Testes de integração garantem confiança ao fazer grandes refatorações ou adicionar novos recursos
- Aplicação de uma abordagem de desenvolvimento exploratório: implementação e validação repetidas com test ROMs
UI no navegador e otimização de desempenho
- Com js_of_ocaml, é fácil gerar builds em JS
- A biblioteca Brr permite acessar a API DOM do Javascript de forma segura no estilo de OCaml
- O desempenho inicial (20 FPS) era baixo, mas o profiler do Chrome foi usado para analisar gargalos em GPU, timer, Bigstringaf etc.
- Foram feitos commits de otimização em cada módulo, e ao desativar inlining ineficiente no build JS foi possível atingir 60 FPS finais (PC/móvel)
- Em build nativo, o desempenho chega a 1000 FPS
Benchmark e comparação de hardware
- Foi implementado um modo de benchmark headless para medir FPS em cada ambiente
Desenvolvimento de emulador e habilidades profissionais
- De forma parecida com programação competitiva, há repetição do ciclo: interpretar especificações claras → implementar → validar
- É uma experiência que ajuda de forma prática no desenvolvimento e teste orientados por especificações
Evolução recente do ecossistema e das ferramentas de OCaml
- dune oferece uma experiência de sistema de build simples
- Ferramentas como Merlin e OCamlformat facilitam autocompletar, navegação no código e formatação
- setup-ocaml também pode ser aplicado facilmente ao GitHub Actions
Reflexões sobre linguagens funcionais
- Há questionamento em relação à explicação de que linguagens funcionais significam minimizar efeitos colaterais
- Estado mutável escondido sob abstrações é usado ativamente em nome do desempenho
- O autor prefere tipagem estática, pattern matching, sistema de módulos e inferência de tipos
Incômodos e custo de dependência de abstrações
- A padronização do gerenciamento de dependências ainda é complexa e pouco explicada (como no opam)
- Ao adicionar abstrações com uma estrutura de módulos e functors, pode ser necessário modificar toda a estrutura da hierarquia de dependências
- Diferentemente de OOP, ao introduzir abstrações pode ser preciso mudar até a forma de escrever módulos dependentes de nível superior
Materiais de estudo recomendados
- Learn OCaml Workshop: para iniciantes, com código prático e testes
- Real World OCaml: aprender o estilo real de OCaml com exemplos de uso profissional
- The Ultimate Game Boy Talk: vídeo com visão geral da arquitetura
- gbops, Game Boy CPU Manual, Pandocs, Imran Nazar’s blog: materiais de referência sobre instruções e hardware do Game Boy
Conclusão
- Com o projeto CAMLBOY, foi possível vivenciar de forma prática recursos avançados de OCaml, testes, abstração e compatibilidade com navegadores
- Também ficaram claros tanto os pontos fortes quanto as limitações obtidos com a evolução do ecossistema e a experiência real de desenvolvimento
- Desenvolver emuladores ajuda de forma concreta a elevar o nível técnico de desenvolvedores intermediários ou mais experientes
1 comentários
Comentários do Hacker News
Fico curioso para saber se alguém diria com confiança que alguma linguagem de programação específica é mais adequada para escrever emuladores, máquinas virtuais e interpretadores de bytecode. Aqui, o critério de "melhor" não é desempenho nem redução de erros de implementação, mas sim o quanto é mais intuitiva para implementar e explorar por conta própria, o quanto se aprende e o quanto a própria experiência de implementar é recompensadora e divertida. Por exemplo, Erlang tem um objetivo claro no domínio de sistemas distribuídos, e o conhecimento de domínio e o design da linguagem estão alinhados com essa área, então usá-la leva a uma compreensão mais profunda tanto de sistemas distribuídos quanto do próprio Erlang. Fico me perguntando se existe alguma linguagem em que o alvo seja algo como "expressar o funcionamento de uma máquina em código"
Quero enfatizar que, para mim, linguagens de programação de sistemas como C, C++, Rust e Zig são as escolhas de maior "satisfação". Nessas linguagens, tipos de dados como
uint8mapeiam diretamente para bytes na memória, e operações comomemcpyequivalem imediatamente a um trabalho de blit. Quase não existe o sofrimento de ficar adaptando um tipo comoNumberdo JavaScript para usá-lo como byte em operações de bits. Ao fazer um emulador em JavaScript, você esbarra nesse tipo de problema imediatamente. Claro, desde que a linguagem ofereça exibição gráfica e memória suficiente, no fim quase qualquer uma consegue rodar algo parecido, e a maior diversão acaba vindo de escolher a linguagem com a qual você se sente mais confortávelHaskell tem ótimo desempenho nas transformações de dados necessárias para DSLs e compiladores. OCaml, Lisp e linguagens modernas com pattern matching e ADTs também são todas adequadas. C++ moderno também pode tentar algo parecido com tipos
variante afins, mas não fica tão limpo. Se a ideia é realmente rodar jogos no emulador, C ou C++ são as escolhas padrão. Rust talvez também dê conta razoavelmente bem, mas não sei muito sobre manipulação de memória em baixo nívelSou da opinião de que não existe uma linguagem especialmente melhor para criar emuladores, máquinas virtuais e interpretadores de bytecode. Se houver arrays (acesso em tempo constante a índices arbitrários) e operações de bits, a implementação fica muito fácil. Num nível em que nem se considera JIT, até linguagens funcionais oferecem arrays e operações de bits
Eu recomendaria sml, mais especificamente o dialeto MLTon. Ele compartilha quase todos os motivos pelos quais OCaml é bom, mas pessoalmente eu o considero uma versão mais completa entre as linguagens da família ML. O que sinto falta do OCaml é basicamente applicative functor, mas isso é só uma diferença leve na estrutura de módulos, não algo grande
Para algo focado em diversão e experimentação dentro do navegador, Elm também é uma boa opção. Recomendo ver o projeto parecido elmboy
Este texto é excelente não só por usar OCaml, mas também por organizar muito bem o processo de implementação de um emulador de Game Boy. É um material realmente incrível. Deixo meus agradecimentos ao autor. Além disso, há muito tempo tenho a ideia de que, se alguém criasse dentro do navegador um editor de assembler e uma SPA que reunisse assembler/linker/loader, permitindo que qualquer pessoa experimentasse facilmente o desenvolvimento homebrew para Game Boy, isso seria ótimo para ensino de desenvolvimento embarcado
Fico pensando se alguém estaria procurando um tutorial sobre implementação de som em emuladores de Game Boy. A maioria dos tutoriais não explica a parte de áudio e, mesmo tentando implementar por conta própria, foi difícil entender e construir só com o material disponível
Não é um tutorial oficial, mas compartilho um material de 2 slides resumindo a forma como implementei isso: slides O som do Game Boy tem 4 canais, e cada canal produz a cada tick um valor entre 0 e 15. O emulador precisa somar isso (média aritmética), escalar para a faixa de 0 a 255 e enviar para o buffer de áudio. De acordo com a taxa de ticks (4,19MHz) e a saída de áudio (22kHz etc.), deve-se emitir um valor aproximadamente a cada 190 ticks. As características de cada canal estão bem resumidas neste material. Os canais 1 e 2 são ondas quadradas (repetição de 0/15), o canal 3 é forma de onda arbitrária (leitura de memória), e o canal 4 é ruído, baseado em LSFR. Recomendo consultar o código de exemplo SoundModeX.java
Este material também é bem bom
Este vídeo no YouTube também vale a pena conferir
Minha impressão é de que é um texto muito bom e um projeto muito legal
Chamou a atenção que a demo roda rápido demais. O checkbox de Throttle quase não faz efeito. Na verdade, quando desmarca parece até ficar mais lento. Com Throttle ligado fica em 240fps, desligado em 180fps. Quando o Throttle está ligado, 1 segundo parece virar uns 4 segundos no emulador real. Provavelmente isso tem relação com o fato de o monitor ter taxa de atualização de 240Hz
requestAnimationFrame()e esqueceram de calculardeltaTimeAcho que é um texto realmente bonito. Obrigado por compartilhar esse material. Fiquei com vontade de tentar fazer eu mesmo um emulador de Game Boy em Rust, e como o post do blog foi uma grande inspiração, já deixei salvo nos favoritos
É um exemplo muito legal de uso de functor e GADT. Queria comparar com emuladores de CHIP 8 ou NES, e também parece interessante portar o CAMLBOY para WASM com ocaml-wasm
wasm_of_ocaml), provavelmente já dá para rodar o CAMLBOY em WASM