1 pontos por GN⁺ 1 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • Fame Boy é um emulador de Game Boy implementado em F#, roda no desktop e na web com suporte a som, e tem versão para jogar no navegador e código-fonte no GitHub
  • O núcleo do emulador e o frontend foram simplificados para compartilhar apenas framebuffer, audiobuffer, stepEmulator() e getJoypadState(state), enquanto o stepper executa CPU, timer, serial, APU e PPU em sequência para manter a sincronização em thread única
  • A implementação da CPU usa uniões discriminadas e match do F# para modelar 512 opcodes em 58 instruções, e foi projetada para impedir em nível de tipo estados ilegais como escrever em valores imediatos com os tipos From e To
  • A PPU adotou renderização por scanline em vez do pixel FIFO do Game Boy real, ficando mais rápida e simples, mas alguns jogos que dependem do timing da fila de pixels podem não funcionar corretamente
  • A adaptação para a web foi resolvida com Fable e, depois de corrigir o problema de operações de bits de 8 e 16 bits seguirem a semântica de 32 bits do JavaScript, passou a funcionar com um bundle JS de cerca de 100KB; com otimizações de desempenho e build de release, chegou a aproximadamente 1000 FPS no desktop

Contexto e objetivo do projeto

  • Mesmo trabalhando há mais de 8 anos como engenheiro de software, ele sentia que não entendia como os computadores realmente funcionam, então decidiu aprender construindo um emulador por conta própria
  • Como jogou muito Pokémon na infância, escolheu o Game Boy como alvo, por ser hardware real, ter um escopo relativamente simples e também uma forte conexão pessoal
  • Antes de ir direto para o Game Boy, fez o curso From NAND to Tetris para entender os elementos básicos de um computador, como registradores, memória e ALU
  • Para se familiarizar com a criação de emuladores, primeiro implementou em F# o emulador de CHIP-8 Fip-8
  • Depois de alguns meses de trabalho, concluiu o emulador de Game Boy Fame Boy, com som e execução tanto no desktop quanto na web
  • Dá para jogar no navegador e o código-fonte está disponível no GitHub

Estrutura do emulador

  • Para funcionar tanto no desktop quanto na web, a interface entre o núcleo do emulador e o frontend foi mantida simples
  • A interface principal entre frontend e núcleo é composta por dois arrays e duas funções
    • framebuffer: um array de tons 160×144 que contém branco, tom claro, tom escuro e preto
    • audiobuffer: um ring buffer de áudio com sample rate de 32768Hz, com cabeças de leitura e escrita
    • stepEmulator(): executa uma instrução da CPU e retorna a quantidade de ciclos consumidos
    • getJoypadState(state): callback pelo qual o frontend envia o estado do joypad ao emulador, normalmente chamada uma vez por frame
  • O Fame Boy é modelado de forma parecida com o hardware real do Game Boy
    • A CPU não conhece nenhum hardware além do mapa de memória, como no Sharp LR35902 do Game Boy real, e usa apenas o IoController para sinais de interrupção
    • A CPU é a parte mais “F#” do codebase e faz bastante uso de modelagem de domínio funcional
    • Memory.fs armazena a maior parte da RAM do Game Boy e atua como mapa de memória e barramento entre CPU, IO Controller e cartucho
    • Por desempenho, Memory.fs compartilha referências para arrays de VRAM e OAM RAM com a PPU
    • IoController.fs foi separado quando Memory.fs passou a concentrar lógica demais; embora não exista um único controlador de IO no hardware real do Game Boy, ele reúne em um só lugar o tratamento dos registradores de hardware para manter as interfaces dos componentes simples e seguras
  • A função stepper em Emulator.fs atua como a cola que une o emulador inteiro, combinando as funções de execução em etapas de cada componente
let stepper () =
    // Execute a single instruction
    // Each instruction uses a different amount of cycles
    let mCycles = stepCpu cpu io

    for _ in 1..mCycles do
        stepTimers timer io
        stepSerial serial io
        // The APU technically runs at 4x CPU-cycles, but can be batched
        stepApu apu

    let tCycles = mCycles * 4

    // The PPU operates at 4x CPU-cycles. The APU should be here too
    for _ in 1..tCycles do
        stepPpu ppu

    // Return cycles taken so the frontend runs the emulator at the right speed
    mCycles
  • Os componentes do hardware real executam em paralelo com base em um oscilador mestre central, mas como o Fame Boy é single-thread, os componentes precisam ser executados em sequência
  • A função stepper centraliza a execução para garantir que todos os componentes permaneçam sincronizados
  • Para atingir uma velocidade jogável, ele precisa rodar com o número correto de ciclos por segundo, e são necessários cerca de 17500 ciclos de CPU por frame a 60 FPS
  • O frontend aciona o emulador pela taxa de amostragem de áudio quando o som está ligado e, quando está no mudo, pela taxa de frames

Implementação da CPU e F#

  • O emulador de CHIP-8 foi escrito de forma puramente funcional, sem membros mutable, copiando até os arrays, mas o Fame Boy usa estado mutável de forma ativa

  • O Game Boy é muito mais rápido que o CHIP-8, e copiar mais de 16KB de memória milhões de vezes por segundo não é uma abordagem adequada

  • O motivo para usar F# no Fame Boy é que o sistema de tipos rico da linguagem combina bem com a modelagem das instruções da CPU, além de o autor simplesmente gostar de F#

  • Modelagem de domínio

    • Ao implementar a CPU, ele seguiu o Gekkio’s Complete Technical Reference e agrupou as instruções da mesma forma que o documento
    • No início, colocou uniões discriminadas por tipo de instrução em Instructions.fs
    • type LoadInstr = | Load8Immediate of uint8 | Load8Direct of Register | Load8Indirect // ... other load instructions
  • type ArithmeticInstr = | IncrementDirect of uint8 | IncrementIndirect of Register // ... other arithmetic instructions

    • Várias instruções compartilham o conceito comum de posição do operando

      • immediate, que lê o valor em byte na memória logo após a instrução
      • direct, que lê e escreve registradores da CPU
      • indirect, que lê e escreve a posição de memória apontada pelo registrador HL da CPU
    • Ao extrair o conceito de posição e separá-lo nos tipos From e To, foi possível representar as instruções de carga de forma mais concisa

    • type To = | Direct of Register | Indirect

    • type From = | Immediate of uint8 | Direct of Register | Indirect

    • type LoadInstr = | Load of From * To // These form a tuple, like Load<From, To> in C# // ... other instructions

    • Com esse método, as instruções da CPU foram reduzidas de 512 opcodes para 58 instruções

    • Generalizar o domínio pode trazer o risco de permitir estados inválidos, mas isso pode ser evitado com o sistema de tipos

    • Se um único tipo de posição Loc fosse usado no lugar de From e To, instruções inválidas como Load(Loc.Direct D, Loc.Immediate), que salva o valor de um registrador em uma posição de valor imediato, poderiam ser compiladas

    • O hardware do Game Boy não oferece suporte a escrita em valores imediatos, então modelar corretamente o domínio com tipos em F# permite garantir que estados ilegais não sejam representados no sistema

    • Há apenas uma exceção: o opcode 0x76

      • Pelo padrão do opcode, ele teria a forma Load(From.Indirect, To.Indirect), carregando o valor de 8 bits da posição HL para a própria posição HL
      • O tipo do Fame Boy permite isso, mas essa instrução não existe no Game Boy real
      • Logicamente, ela é um NOP e não é perigosa; além disso, na prática, o leitor de opcode decodifica 0x76 como HALT, então ela não pode ser alcançada
    • Depois de usar match e Option do F#, voltar para uma instrução switch comum pareceu grosseiro e propenso a erros, então a recomendação é experimentar uma linguagem funcional

  • Mantendo a simplicidade

    • Como o objetivo do projeto não era criar o melhor emulador, mas aprender sobre hardware de computadores, o autor não analisou profundamente o código de outros emuladores

    • Ao ver o seguinte código no fonte do CAMLBOY, gostou da possibilidade de passar apenas as flags desejadas em ordem arbitrária

    • set_flags ~h:false ~z:(!a = zero) ();

    • Como F# evita sobrecarga de métodos e parâmetros padrão por causa do sistema de tipos que dá suporte à aplicação parcial, não foi possível fazer da mesma forma

    • No início, isso foi implementado passando um array e um tipo de flag, como abaixo

    • cpu.setFlags [ Half, false; Zero, a = 0uy ]

    • Depois, durante a refatoração, isso foi alterado para a seguinte implementação baseada em funções puras em Cpu/State.fs L81

    • module Flags = let inline setZ (v: bool) (f: uint8) = if v then f ||| ZMask else f &&& ~~~ZMask

      let inline setH (v: bool) (f: uint8) = // ... the other flag functions and definitions

    • // Other files

    • cpu.Flags <- cpu.Flags |> setH false |> setZ (a = 0uy)

    • As novas funções são simples funções puras, fáceis de compor e testar

    • A implementação anterior era mais verbosa porque exigia elevar valores para um tipo de união discriminada e colocá-los em um array

    • As novas funções são inline, não exigem alocação no heap e também tiveram desempenho melhor, aumentando o FPS do emulador em cerca de 10%

  • Testes

    • A implementação inicial da CPU seguia o método de executar a ROM de Tetris e implementar cada instrução conforme encontrava um opcode ainda não implementado
    • match opcode with
    • | 0x00 -> Nop
    • | _ -> failwith "Unimplemented opcode"
    • Esse método era repetitivo e cansativo, porque exigia ficar indo e voltando aleatoriamente pela documentação técnica, além de tornar difícil saber se a instrução tinha sido implementada corretamente
    • Para resolver os dois problemas, foram introduzidos testes unitários
    • O código do emulador foi escrito manualmente por aprendizado, mas a IA foi usada para gerar os casos de teste
    • As especificações da documentação técnica foram colocadas no prompt, e os testes baseados nas especificações foram escritos sem que a IA visse o código do emulador
    • Enquanto a IA gerava os testes, o autor lia a especificação e implementava a lógica até os testes passarem, realizando uma verdadeira abordagem de desenvolvimento guiado por testes
    • Os testes também encontraram alguns bugs em instruções que já tinham sido implementadas
    • Os testes foram revisados e aprimorados regularmente, e em vez de atrapalhar o aprendizado, ajudaram a concentrar energia nas partes mais interessantes

Componentes após a CPU

  • PPU

    • O Game Boy não tem uma GPU, e sim uma PPU, ou picture processing unit
    • Muitos textos sobre criação de outros emuladores de Game Boy focavam na CPU e tratavam a PPU em apenas alguns parágrafos, mas no Fame Boy levou mais tempo para entender a PPU
    • A CPU pareceu algo natural graças a From NAND to Tetris e à experiência com CHIP-8, mas a PPU era mais parecida com um trabalho mecânico de seguir etapas para colocar pixels na tela
    • No começo, em vez de tentar entender de uma vez o pixel FIFO e todo o pipeline da PPU, a abordagem foi começar lendo e fazendo o parsing de tiles e mapas de fundo da memória para exibi-los na tela
    • Assim foi possível ver a CPU funcionando e, graças à simplicidade de Tetris, confirmar um resultado que já parecia quase um jogo real de Game Boy
    • A abordagem de começar por tiles e visualização de fundo continuou ajudando, desde a implementação da tela real até a depuração de bugs detalhados nos dados de sprites
    • A PPU do Fame Boy tem uma grande imprecisão em relação ao hardware
      • O Game Boy real usa uma fila FIFO, como um monitor CRT, para colocar os pixels na tela um a um
      • O Fame Boy renderiza a scanline inteira no início do período de desenho daquela linha
    • Essa abordagem é mais rápida e o código é mais simples, e como todos os jogos que se queria jogar funcionavam, não houve necessidade de migrar para uma fila de pixels
    • Jogos que exploram o hardware do Game Boy até o limite e usam o timing da fila de pixels não funcionam corretamente no Fame Boy, mas como a maioria dos jogos não usa o hardware de forma tão agressiva, em geral devem funcionar
  • Joypad

    • Além da PPU e da APU, o joypad também foi tratado
    • A implementação inicial foi muito fácil e escrever testes também foi simples
    • Mas depois de uma grande refatoração, quase sempre quebrava
    • O registrador de hardware do joypad é lido e escrito tanto pela CPU quanto pelo jogo, então a interação é complexa
    • No início, a CPU escrevia o estado do joypad no registrador a cada ciclo, mas como uma pessoa não muda os botões milhões de vezes por segundo, isso foi alterado para atualizar apenas uma vez por frame
    • Como resultado, o direcional parou de funcionar
    • O hardware do Game Boy só consegue ler metade dos botões por vez, e os jogos quase sempre leem o registrador do joypad duas ou mais vezes em intervalos curtos, dependendo do fato de o registrador mudar entre essas leituras
    • Um registrador em cache atualizado uma vez por frame não mudava entre duas leituras, então metade dos botões não funcionava
    • No fim, foi implementado de forma que o IoController atualiza o registrador do joypad apenas quando a CPU faz a leitura
    • Mais detalhes podem ser vistos na documentação de joypad do Pandocs
  • Som

    • Depois de criar um emulador funcional, ao jogar a versão web pareceu que algo faltava sem som, então foi adicionada a APU, ou audio processing unit
    • Descobriu-se que vários emuladores são dirigidos pela taxa de amostragem de áudio do frontend, e não pela taxa de quadros
    • No começo isso pareceu invertido, então foi pesquisada a taxa de amostragem dinâmica e tentou-se implementar um sistema em que a taxa de quadros dirigisse o emulador
    • O som foi conceitualmente o componente mais difícil, e levou tempo para entender o funcionamento dos vários registradores e canais de som
    • Nessa parte, a IA ajudou bastante no papel de professora, com várias rodadas de perguntas e respostas antes de codificar
    • Assim como com a PPU, foi muito satisfatório completar os canais um a um, e ao ouvir a música de Tetris ficando cada vez mais rica também foi possível entender como a música era composta
    • CPU e PPU funcionam de uma forma em que executam exatamente X tarefas por frame, e é fácil calcular X, mas a APU tinha muitos valores a escolher e ajustar
    • Apenas a taxa de amostragem da APU foi fácil de definir
      • A APU real do Game Boy é flexível, então o emulador pode usar a taxa de amostragem que quiser
      • O Fame Boy escolheu 32768Hz
      • Com um clock de CPU de 1048576Hz, 32768Hz equivale a 1 sample a cada 128 ciclos da CPU, então o estado da APU pode ser sincronizado perfeitamente só com inteiros
      • Como 128 também é divisível por 4, dá para processar as etapas da APU em lotes de 4 sem perder o alinhamento com as instruções da CPU
    • Os outros valores eram muito mais instáveis e, como não se era engenheiro de som, foi preciso ajustá-los por tentativa
    • Cada frontend e cada plataforma tinham seus próprios problemas
      • No PC, o som funcionava bem, mas no MacBook parecia o som de uma cachoeira
      • Depois de corrigir o problema no MacBook, a versão para desktop PC deixou de executar por causa de uma condição de corrida
    • A tentativa de resolver isso de forma inteligente com taxa de amostragem dinâmica foi abandonada, e quando o áudio passou a dirigir o emulador, ele ficou muito mais estável em vários dispositivos
    • O áudio é a parte mais frágil da interface entre o emulador e o frontend, mas exige sincronização precisa para evitar dissonâncias

Como o emulador é dirigido

  • A diferença entre direção baseada em áudio e direção baseada em frames está relacionada à percepção humana
  • Quando o sinal de áudio é interrompido, o alto-falante se move bruscamente por causa da mudança repentina no sinal e surge um ruído de estalo
  • Quando o vídeo é interrompido, o player de vídeo pula um ou dois frames porque os dados não chegaram a tempo, mas como isso não empurra nada fisicamente, incomoda menos aos sentidos
  • Dentro do Fame Boy, áudio e vídeo ficam perfeitamente sincronizados por projeto
  • Mas o áudio e o vídeo do computador em execução são independentes, e um dos dois pode atrasar de vez em quando
  • Quando o áudio e o vídeo do frontend saem de sincronia, há duas opções
    • Sincronizar o áudio do frontend com o áudio do emulador e ocasionalmente descartar frames
    • Sincronizar o vídeo do frontend com os frames do emulador e ocasionalmente descartar áudio
  • O lado escolhido “dirige” o emulador, e o outro é mantido o mais próximo possível
  • A direção baseada em taxa de quadros é relativamente simples
let mutable cycles = 0

while (runEmulator) do
    cycles <- cycles + targetCyclesPerMs * lastFrameTime

    while cycles > 0 do
        let cyclesTaken = stepEmulator ()
        cycles <- cycles - cyclesTaken

    draw ppu.framebuffer
  • A direção baseada em som é mais complicada, porque o Raylib e o Web Audio tratam o processamento de áudio de formas diferentes
  • O fluxo geral é o seguinte
let tryQueueAudio apu stepEmulator =
    if frontend.audioBuffer.hasSpace () then
        while apu.writeHead - apu.readHead < samplesNeeded do
            stepEmulator ()

        frontend.audioBuffer.fill apu.audioBuffer

while (runEmulator) do
    tryQueueAudio apu stepEmulator

    draw ppu.framebuffer
  • A diferença principal é que stepEmulator não é mais controlado por lastFrameTime, e sim pela necessidade do buffer de áudio do frontend
  • samplesNeeded precisa calcular o número de chamadas de stepEmulator para se adequar a diferentes taxas de amostragem e ainda produzir 60 FPS
  • Como o buffer de áudio do frontend só se preocupa em se preencher, ele pode chamar stepEmulator vezes demais ou de menos por frame, e como resultado o framebuffer pode não ser atualizado no momento certo
  • No frontend web, é possível testar a versão baseada em frames adicionando ?frame-driven à URL
  • A versão baseada em frames é visualmente mais suave, mas às vezes produz estalos no áudio
  • O frontend web baseado em áudio também muda para o modo baseado em frames quando o botão de mudo é pressionado, já que assim os estalos não são ouvidos
  • A implementação não é perfeita, mas como os estalos no áudio causam uma impressão pior do que travamentos de frame, e o estado sem som parecia vazio, o padrão do frontend web foi definido como baseado em áudio
  • O áudio é uma das poucas áreas do Fame Boy com as quais não se ficou satisfeito, e é uma parte que se gostaria de revisar algum dia

Publicando na web com Fable

  • Depois que a PPU passou a funcionar até certo ponto e algo começou a aparecer na tela no desktop, quis levar o Fame Boy para a web
  • Li a documentação do Fable, instalei os pacotes, configurei o loop principal e adicionei estilos, deixando tudo pronto para rodar em uma ou duas horas
  • A primeira versão com Fable exibiu a tela de forma estranha, e depois de debugar um pouco, tentei usar o WebAssembly do Blazor para não gastar tempo demais
  • O Blazor também foi fácil de executar e, desta vez, de fato funcionou, mas rodava a cerca de 8 FPS, praticamente impossível de jogar
  • Não ficou claro se o problema era do próprio Blazor, e seguir os guias de performance da equipe do .NET também não ajudou
  • Como o debugging também era incômodo, voltei ao Fable para verificar o que estava dando errado no processo de conversão para JavaScript
  • O Fable coloca o arquivo JS convertido bem ao lado do código-fonte, e ele era realmente bem legível
  • Isso facilitou entender o novo código e depurá-lo nas ferramentas de desenvolvedor do navegador
  • Nas ferramentas de desenvolvedor, percebi que os valores dos registradores da CPU estavam estranhos
    • Na Fame Boy e no Game Boy, os registradores da CPU são inteiros sem sinal de 8 bits, então deveriam ficar no intervalo de 0 a 255
    • Mas apareciam valores como -15565461
  • Na documentação do Fable, encontrei a documentação de compatibilidade de tipos numéricos

Operações bit a bit (não padronizadas) para inteiros de 16 bits e 8 bits usam a semântica subjacente de operações bit a bit de 32 bits do JavaScript. Os resultados não são truncados como esperado, e os operandos de shift não são mascarados para caber no tipo de dado.

  • Isso batia exatamente com a explicação de que operações bit a bit em inteiros de 16 bits e 8 bits usam a semântica de 32 bits do JavaScript, e que os resultados não são truncados como se esperaria
  • Depois de encontrar os pontos no código em que os valores de 8 bits precisavam ser truncados e corrigir os problemas relacionados, o frontend web passou a funcionar corretamente
  • Como usa apenas JS sem o runtime do .NET, o bundle web tem cerca de 100 KB
  • Tirando o problema peculiar com uint8, a experiência de usar Fable foi bem agradável, e pude manter todo o código-fonte em F#

Melhorias de desempenho

  • Depois que os resultados começaram a aparecer na tela, adicionei um log simples de FPS no console
  • No início, em modo de debug, ficava em cerca de 55–60 FPS, aparentemente por causa do Raylib tentando manter o v-sync
  • Ao desativar o v-sync, subiu para cerca de 70 FPS, mas surgiu jitter
  • Depois, conforme mais funcionalidades foram sendo adicionadas, o desempenho caiu gradualmente até chegar a 45 FPS, e desligar o v-sync não ajudou
  • Quando rodei o profiler do JetBrains Rider, mapAddress apareceu como um gargalo suspeito
  • Como quase todos os componentes acessam memória, confirmei que o custo desse acesso era maior do que o esperado
  • O código problemático funcionava mapeando endereços de memória para a union discriminada MemoryRegion antes de ler e escrever
type MemoryRegion =
    | RomBase of offset: int
    // ... others

let mapAddress (addr: int) : MemoryRegion =
        match addr with
        | a when a < 0x4000 -> RomBase a
        // ... others

type DmgMemory(arr: uint8 array) =
    // Arrays for romBase etc

    member this.read address =
        match mapAddress address with
        | RomBase i -> romBase[i]
        // ... others

    member this.write address value =
        match mapAddress address with
        | RomBase _ -> ()
        // ... others
  • Eu tentei estender à memória o fluxo obtido ao modelar o domínio da CPU, e o resultado foi que um objeto MemoryRegion era criado e mapeado a cada leitura e escrita de memória
  • Esse método alocava milhões de objetos por segundo no heap e ainda aumentava a quantidade de desvios que o compilador JIT precisava processar
  • Com uma única mudança, removendo a union discriminada e a função de mapeamento e passando a acessar os arrays diretamente, o FPS dobrou
  • Depois, nos benchmarks, pareceu que a maior parte da melhora veio de otimizações do JIT relacionadas a desvios e a call sites localizados
  • Mesmo mudando MemoryRegion para uma struct DU para que fosse alocada na stack, o desempenho melhorou só cerca de 15%; os 85% restantes vieram da remoção da DU e da função de mapeamento
  • Depois disso, houve mais casos em que migrei para struct DU ou adotei abordagens menos amigáveis ao estilo típico de F#
  • A necessidade de otimização surgiu a partir do momento da implementação da PPU, e foi preciso abrir mão de parte do F# mais idiomático
  • Observando o profiler regularmente e melhorando a performance aos poucos, consegui chegar a cerca de 120 FPS
  • O maior ganho de FPS foi simplesmente desligar a build de debug; em modo release, chegou a cerca de 1000 FPS
  • Até o fim, o desempenho foi monitorado e ajustado regularmente

Benchmark

  • Como olhar apenas para o número de FPS no console não parecia uma boa forma de medir desempenho, no meio do projeto adicionei um projeto com BenchmarkDotNet para medir a performance no desktop
  • Depois, criei também um benchmark web simples usando Node.js para estimar de forma parecida o desempenho no navegador
  • Para testar cenários realistas, os benchmarks usaram as seguintes demo ROMs
    • Flag: loop curto sem som
    • Roboto: demo de longa duração, com mais de 1 minuto, usando muitos efeitos visuais e som
    • Merken: semelhante ao Roboto, mas usa ROM com memory banking para testar a memória
  • A performance em FPS no desktop, em um PC Windows com Ryzen 9 7900 e em um MacBook Air M4, foi a seguinte
CPU Flag Roboto Merken
Ryzen 9 7900 1785 1943 1422
Apple M4 1907 2508 1700
  • A performance em FPS na web foi a seguinte
CPU Flag Roboto Merken
Ryzen 9 7900 646 883 892
Apple M4 779 976 972
  • O Fame Boy funciona de forma razoável em ambas as plataformas
  • Ao contrário do esperado, a APU, ou seja, o som, tem mais impacto no desempenho do emulador do que a PPU
  • Desativar a PPU aumenta a performance no desktop em cerca de 250 FPS, mas desativar a APU aumenta em cerca de 500 FPS

Uso de IA

  • Mesmo em um projeto de aprendizado, considerou impossível evitar completamente a influência da IA, então deixou registrado de forma transparente como ela foi usada
  • Ao longo de todo o processo, a IA foi usada principalmente como ferramenta de apoio
    • pedidos de code review
    • um interlocutor para discutir ideias
    • interpretação concisa de documentação técnica
  • Tentou reduzir ao máximo o código escrito por IA
  • Como queria criar um resultado que pudesse mostrar a outras pessoas com orgulho, preferiu deixar código feito por ele mesmo em vez de apenas compartilhar prompts
  • PR de melhoria de desempenho

    • No fim do projeto, passou o repositório para uma CLI e pediu que ela encontrasse melhorias de desempenho
    • Ela deu algumas ideias e também tentou outras abordagens por conta própria, elevando o desempenho em mais do que o dobro em alguns benchmarks
    • Os detalhes estão no PR
    • Mas bugs também foram introduzidos, e foi preciso encontrá-los e corrigi-los manualmente
    • Uma das grandes melhorias de desempenho, “atualizar o STAT apenas ao trocar de mode/LY”, quebrava alguns jogos e demos que dependiam de atualizações mais frequentes, e isso foi corrigido neste commit de correção
  • “inverno do timer”

    • Há um grande vazio no histórico do Git, e esse período foi chamado de “timer winter”

    • Não foi porque o trabalho no emulador tinha parado, mas porque havia um bug impedindo de passar da tela de copyright do Tetris

    • Foram mais de 20 horas depurando, pesquisando no Discord de emu-dev, criando testes e até jogando o problema para modelos iniciais de IA, mas nada resolveu

    • Depois de fazer uma pausa de algumas semanas, tentou o Claude Opus, que encontrou o problema em poucos minutos

    • O problema era que o timer avançava apenas uma vez por instrução, em vez de avançar de acordo com a quantidade de ciclos consumidos pela instrução

    • let stepEmulator () = let cyclesTaken = stepCpu cpu

      // Before stepTimers timer memory // only once per instruction

      // The fix for _ in 1..cyclesTaken do // cpuCycles can vary between 1 and 6 stepTimers timer memory

    • Como os ciclos da CPU podem variar de 1 a 6, na implementação anterior o timer rodava, em média, de 2 a 3 vezes mais devagar do que deveria

    • A tela de copyright só estava demorando mais para desaparecer; o problema foi simplesmente nunca ter esperado 1 ou 2 minutos

    • O texto principal em si foi escrito majoritariamente por ele mesmo

Lições aprendidas e conclusão

  • O objetivo principal era aprender como os computadores funcionam, e nesse ponto o projeto foi um grande sucesso
  • O trabalho foi muito divertido, a ponto de começar pensando “hoje vou implementar só uma funcionalidade” e acabar indo até as 2 da manhã tentando corrigir só mais um bug
  • Pensou em tentar também um Game Boy Advance, mas ao olhar as especificações pareceu que o ganho em compreensão de hardware seria de cerca de 20%, enquanto o esforço exigido seria algo como 3 vezes maior
  • O Game Boy ofereceu um bom equilíbrio para aprendizado, e por enquanto dá para parar por aqui
  • Não tem certeza se isso o tornou um engenheiro de software melhor, mas certamente passou a entender um pouco mais sobre as ferramentas que usa todos os dias
  • Perguntas ou comentários podem ser enviados por e-mail

1 comentários

 
GN⁺ 1 시간 전
Comentários do Hacker News
  • Fiquei feliz em ver F# por aqui! Emuladores são uma ótima forma de aprender uma linguagem e, à primeira vista, parece que ele equilibrou bem entre um F# mais idiomático e um menos idiomático dependendo da tarefa
    Uma melhoria simples para reduzir alocações seria adicionar [<Struct>] à união discriminada em Instructions.fs e reutilizar nomes de campos para reaproveitar os campos internos
    É um detalhe pequeno, mas parte do tratamento de registradores parece confusa. Como já é do tipo byte, fazer a &&& 0xFFuy no setter aparentemente não acrescenta nada além de member val A = 0uy with get, set. Parece mais um vestígio de mudanças feitas durante o desenvolvimento

    • Há este comentário no código de Register: diz que registradores não podem ser um tipo record porque o valor precisa ser truncado para 8 bits na escrita, então é necessário um setter
      A explicação é por causa do renderizador web: o Fable converte uint8 em Number no JS, o que pode ultrapassar 8 bits, e não aplica o truncamento
      Então isso parece ser um código mais conservador para higienizar os dados por causa da característica do Fable de ampliar para Number do JS no alvo web
    • Isso de fato é abordado no texto na parte sobre o port para Fable. Ele também disse que tentou com Blazor
  • Finalmente alguém colocou esforço humano real para aprender alguma coisa, em vez de “o LLM me ajudou a fazer X em Y minutos”
    Ainda parece haver um pouco de esperança para a humanidade

    • Esse jeito sempre vai continuar existindo. Em 2026 ainda há gente fazendo coisas com ferramentas manuais, então vamos chamar isso de programação artesanal
    • Na minha opinião, a esperança na humanidade já devia ter sido abandonada ali pela época da queda da União Soviética
      Dito isso, emuladores são realmente muito legais, e um emulador de GBA é um ótimo alvo para tentar fazer por conta própria
    • Vivi por muito tempo como desenvolvedor de F# e também passei anos sofrendo assédio no meio acadêmico de STEM, então não uso LLMs. Um motivo importante é que o ChatGPT-3.5 deixava óbvio demais que estava copiando e colando de repositórios GitHub de F#
      Não passava nenhuma sensação de AGI; parecia uma máquina de plágio sem o enfeite
      Em algum momento alguém na Microsoft deve ter percebido isso e disparado o alarme de RLHF, então o GPT melhorou bastante e hoje até parece razoavelmente útil para F#. Um desenvolvedor de F# sem princípios talvez esteja se saindo bem com agentes hoje em dia
      Mas minha reação não foi “resolveram o problema do plágio, agora vamos gerar bugigangas”, e sim “agora o plágio do ChatGPT já não vai mais ficar tão escancarado”
      Não quero rolar um d100 ou d1000 na chance de destruir completamente um dos meus valores centrais só para ganhar produtividade. Prefiro continuar devagar e desempregado. Falando sério, estou migrando para instalação de painéis solares e coleta de sucata
      O problema de “os alunos não querem pensar” é muito anterior aos LLMs. Em 2007 fiz uma disciplina avançada de equações diferenciais parciais e, como eu era um dos poucos realmente querendo estudar PDE, resolvi quase toda a lista; por fraqueza psicológica, não consegui negar aos preguiçosos maldosos do curso de matemática, e quase todo mundo copiou minha lição. Aconteceu de novo no programa de pós em matemática. É realmente difícil de acreditar. Se é para isso, nem entendo por que estão naquele curso
  • Ah, F#, meu maior amor. Queria que o pessoal do C# olhasse para isso em vez de continuar transformando C# numa linguagem que tenta fazer tudo e faz tudo meio mal
    Se você monta um projeto usando C# e F# juntos, não sei por que eles não percebem que dá para obter de forma realmente funcional e ergonômica várias dessas coisas que continuam sendo adicionadas ao C#. A interoperabilidade também é excelente

    • Só que, vindo do mundo de OCaml, é uma pena sentir que F# fica um pouco preso à sombra do C#
      Dá para ir bem longe usando F# como linguagem funcional, mas em algum momento você vai querer interoperar com o ecossistema .NET, e aí acaba codando num estilo híbrido meio estranho entre orientação a objetos e programação funcional
  • F# é uma boa linguagem, mas parece condenada a ficar para sempre na sombra do C#. Uma parte considerável do código de bibliotecas vem herdada de C# e .NET, muitas vezes sem interfaces ou bibliotecas pensadas com F# em mente, e com frequência sem documentação explícita de como usar aquilo em F#

    • Traduzir o uso de uma biblioteca de C# para F# é um trabalho bem mecânico, então nem sei se documentação separada é tão necessária assim
      O problema maior é que a comunidade C# gosta de orientação a objetos, então, se você quer trabalhar de forma funcional, muitas vezes precisa embrulhar essas bibliotecas numa forma mais “funcional”
      Ainda assim, acho isso muito melhor do que não ter nada. Gosto de Haskell e OCaml também, mas nesse ponto a comparação pesa
    • É verdade que a interação entre os dois cria alguma estranheza, mas acho que a questão maior não é tanto uma biblioteca específica precisar ser mapeada para cair bem em F#, e sim entender bem as regras de interoperabilidade e a forma da saída interna gerada
      A interoperabilidade com C# afrouxa algumas garantias das quais o código F# normalmente depende, especialmente a imutabilidade. Também surgem limitações inesperadas em genéricos por causa da forma como isso é mapeado para C#
  • Muito legal! Gosto de F#, mas, depois de escrever um pequeno interpretador de Smalltalk em F#, confirmei que, nesse tipo de trabalho, usá-lo do jeito “pretendido” não o torna exatamente um monstro de velocidade

    • Em F#, já vi desempenho melhor quando se escreve de forma quase estupidamente imperativa, mas mantendo os efeitos colaterais confinados dentro de funções. Assim, as funções continuam essencialmente “puras”, mas ainda com velocidade decente
      Por exemplo, normalmente eu gosto da estrutura Map, e ela é uma estrutura imutável bem boa para a maioria dos usos. Mas, quando desempenho importa, não é difícil cair num loop imperativo chato usando um hash map comum
      Se você encapsular tudo dentro de uma única função, em geral dá para evitar a sensação de que o código ficou sujo demais
    • Fico curioso sobre quando você escreveu esse interpretador. O ecossistema .NET inteiro teve ganhos enormes de velocidade nos últimos anos, então a diferença é grande, especialmente para quem usou pela última vez na era do Framework
      Houve até bastante trabalho em melhorias de tail call que nem o compilador de C# aproveita. Por volta do .NET 9 ou 10, F# também deve ganhar um recurso para emitir erro de compilação quando houver recursão sem tail call, para evitar esse tipo de tropeço acidental
    • Se você tomar cuidado com quais recursos usa e quando usa, F# também pode ser muito rápido. Dá para usar o paradigma funcional quando quiser e, se precisar, escrever código imperativo de baixo nível nos loops quentes
      Só não é Rust se você espalhar listas encadeadas, sequências e tipos imutáveis por todo lado
  • Projeto muito legal! Gosto muito de ver esse tipo de coisa
    Ao mesmo tempo, e isso não é uma avaliação do autor nem do trabalho em si, ver como o código em F# fica em projetos reais me fez sentir que posso abandonar a vontade de aprender e usar F#
    A parte puramente funcional é bonita, mas, quando desce para código mais imperativo ou mutável, acho que fica bem feio de olhar. Infelizmente parece que, na maioria dos projetos reais, você acaba tendo de fazer isso
    Então não sei se o certo é escolher outra linguagem funcional e mergulhar nela, ou se é melhor focar em aplicar conceitos funcionais na linguagem que eu já uso. Minha linguagem principal é C#, e o suporte a paradigmas funcionais lá só aumenta, então a segunda opção acaba sendo bem fácil

  • Emuladores escritos em linguagem funcional sempre impressionam. Normalmente é muito mais fácil mapear hardware para uma linguagem imperativa. É divertido ver que tipo de abstrações funcionais as pessoas inventam

    • Fico curioso se você viu o código. F# tem variáveis mutáveis e arrays, e este projeto usa isso, por exemplo, na memória
  • F# é uma linguagem realmente divertida, e belo trabalho!

  • F# é a linguagem de programação que eu amo mas jamais conseguiria usar no trabalho. Fora de projetos pessoais, não tenho oportunidade de usá-la :(

  • Texto interessante e agradável. Gostei da parte de modelagem de dados. Tenho mexido um pouco com OCaml, e esse tipo de modelagem é a melhor parte
    Também foi interessante conhecer o CAMLBOY. Como feedback ao autor, eu pularia a etapa de edição com IA. Acho que eu teria preferido alguns erros gramaticais ou expressões menos polidas a um texto um pouco sem graça como este