Construyó un emulador de Game Boy en F#
(nickkossolapov.github.io)- Fame Boy es un emulador de Game Boy implementado en F# que funciona en escritorio y web, incluye sonido, y tiene disponible el juego en navegador junto con el código fuente en GitHub
- Simplificó el núcleo del emulador y los frontends para que solo compartan
framebuffer,audiobuffer,stepEmulator()ygetJoypadState(state); además, elstepperejecuta CPU, temporizadores, serial, APU y PPU de forma secuencial para mantener la sincronización en un solo hilo - La implementación de la CPU usa discriminated unions y
matchde F# para modelar 512 opcodes como 58 instrucciones, y está diseñada para impedir a nivel de tipos estados inválidos como escribir en valores inmediatos mediante los tiposFromyTo - La PPU eligió renderizado por scanline en lugar del pixel FIFO del Game Boy real, lo que la hace más rápida y simple, aunque algunos juegos que aprovechan el timing de la cola de píxeles podrían no funcionar correctamente
- La portabilidad web se resolvió con Fable y, tras corregir problemas donde las operaciones de bits de 8 y 16 bits seguían la semántica de 32 bits de JavaScript, logró funcionar con un bundle JS de unos 100KB; además, con optimización de rendimiento y builds de release alcanzó alrededor de 1000 FPS en escritorio
Contexto y objetivos del proyecto
- Aunque llevaba más de 8 años trabajando como ingeniero de software, sentía que no entendía cómo funciona realmente una computadora, así que decidió aprender construyendo un emulador por su cuenta
- Como jugó mucho Pokémon de niño, eligió el Game Boy: era hardware real, relativamente acotado en complejidad y además tenía una conexión personal fuerte
- Antes de meterse de lleno con Game Boy, tomó From NAND to Tetris para entender los componentes básicos de una computadora como registros, memoria y ALU
- Para familiarizarse con la creación de emuladores, primero implementó en F# el emulador de CHIP-8 Fip-8
- Tras trabajar durante varios meses, completó Fame Boy, un emulador de Game Boy con sonido que corre tanto en escritorio como en la web
- Se puede jugar en el navegador y el código fuente está publicado en GitHub
Estructura del emulador
- Para que funcionara tanto en escritorio como en la web, mantuvo simple la interfaz entre el núcleo del emulador y el frontend
- La interfaz principal entre frontend y núcleo se compone de dos arreglos y dos funciones
framebuffer: arreglo de tonos de 160×144 que contiene blanco, tono claro, tono oscuro y negroaudiobuffer: buffer de audio circular con sample rate de 32768Hz y cabezales de lectura y escriturastepEmulator(): ejecuta una instrucción de CPU y devuelve la cantidad de ciclos consumidosgetJoypadState(state): callback con el que el frontend pasa el estado del joypad al emulador, normalmente una vez por frame
- Fame Boy está modelado de forma similar al hardware real del Game Boy
- La CPU no conoce el hardware fuera del mapa de memoria, igual que la Sharp LR35902 real del Game Boy, y solo usa IoController para las señales de interrupción
- La CPU es la parte más "F#" de todo el código y usa bastante modelado de dominio funcional
- Memory.fs guarda la mayor parte de la RAM del Game Boy y cumple la función de mapa de memoria y bus entre la CPU, el controlador IO y el cartucho
- Por rendimiento, Memory.fs comparte referencias a arreglos de VRAM y OAM RAM con la PPU
- IoController.fs se separó cuando Memory.fs empezó a acumular demasiada lógica; aunque el hardware real del Game Boy no tiene un único controlador IO, reunir en un solo lugar el manejo de registros de hardware simplifica y hace más seguras las interfaces de cada componente
- La función
stepperde Emulator.fs actúa como el pegamento que une todo el emulador, combinando las funciones de ejecución por pasos 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
- Los componentes del hardware real se ejecutan en paralelo a partir de un oscilador maestro central, pero como Fame Boy es de un solo hilo, sus componentes deben ejecutarse de forma secuencial
- La función
steppercentraliza la ejecución para que todos los componentes se mantengan sincronizados - Para alcanzar una velocidad jugable, debe ejecutarse con la cantidad correcta de ciclos por segundo; se necesitan alrededor de 17500 ciclos de CPU por frame a 60 FPS
- Si el sonido está activado, el frontend impulsa el emulador con la tasa de muestreo de audio; si está en mute, lo impulsa con el framerate
Implementación de la CPU y F#
-
El emulador de CHIP-8 se escribió de forma pura, sin miembros
mutabley copiando también los arreglos, pero Fame Boy usa estado mutable de forma intensiva -
Game Boy es mucho más rápido que CHIP-8, y copiar más de 16KB de memoria millones de veces por segundo no resulta adecuado
-
La razón para usar F# en Fame Boy es que su sistema de tipos rico encaja bien con el modelado de instrucciones de CPU, además de que al autor simplemente le gusta F#
-
Modelado de dominio
- Al implementar la CPU siguió Gekkio’s Complete Technical Reference y agrupó las instrucciones igual que ese documento
- Al principio colocó en Instructions.fs discriminated unions por tipo de instrucción
-
- type LoadInstr = | Load8Immediate of uint8 | Load8Direct of Register | Load8Indirect // ... other load instructions
-
type ArithmeticInstr = | IncrementDirect of uint8 | IncrementIndirect of Register // ... other arithmetic instructions
-
-
Varias instrucciones comparten el concepto común de la ubicación del operando
immediate, que lee el valor de un byte de la memoria justo después de la instruccióndirect, que lee y escribe registros de la CPUindirect, que lee y escribe la ubicación de memoria a la que apunta el registro HL de la CPU
-
Al extraer el concepto de ubicación y dividirlo en los tipos
FromyTo, pudo expresar las instrucciones de carga de forma más 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
-
-
Con este enfoque, redujo las instrucciones de CPU de 512 opcodes a 58 instrucciones
-
Generalizar el dominio puede implicar el riesgo de permitir estados inválidos, pero eso puede evitarse con el sistema de tipos
-
Si se usara un solo tipo de ubicación
Locen lugar deFromyTo, una instrucción inválida comoLoad(Loc.Direct D, Loc.Immediate)podría compilar, almacenando el valor de un registro en una ubicación de valor inmediato -
El hardware de Game Boy no permite escribir en valores inmediatos, así que al modelar correctamente el dominio con tipos de F#, se puede garantizar que los estados ilegales no queden representados en el sistema
-
Hay una sola excepción: el opcode
0x76- Si se mira solo el patrón del opcode, toma la forma de
Load(From.Indirect, To.Indirect), como si cargara el valor de 8 bits de la ubicación HL en esa misma ubicación HL - El tipo de Fame Boy lo permite, pero esa instrucción no existe en el Game Boy real
- Lógicamente es un NOP y no es peligrosa, y en la práctica no puede alcanzarse porque el lector de opcodes decodifica
0x76comoHALT
- Si se mira solo el patrón del opcode, toma la forma de
-
Después de usar
matchy Option de F#, al volver a unswitchnormal se siente tosco y propenso a errores, así que recomienda probar un lenguaje funcional
-
-
Mantenerlo simple
-
Como el objetivo del proyecto no era hacer el mejor emulador, sino aprender sobre hardware de computadoras, no revisó en profundidad el código de otros emuladores
-
Al ver el siguiente código en el fuente de CAMLBOY, le gustó que se pudieran pasar solo las banderas deseadas en cualquier orden
-
-
set_flags ~h:false ~z:(!a = zero) ();
-
-
F# no pudo hacerlo de la misma manera porque, debido a su sistema de tipos compatible con aplicación parcial, evita la sobrecarga de métodos y los parámetros por defecto
-
Al principio lo implementó pasando un arreglo y un tipo de bandera, así
-
-
cpu.setFlags [ Half, false; Zero, a = 0uy ]
-
-
Más adelante, durante una refactorización, lo cambió a una implementación basada en funciones puras como la siguiente en 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)
-
-
Las nuevas funciones son fáciles de combinar y probar, y son funciones puras y simples
-
La implementación anterior era más verbosa porque había que elevar los valores a un tipo de unión discriminada y meterlos en un arreglo
-
La nueva función es
inliney no requiere asignaciones en el heap, así que también mejoró el rendimiento y elevó los FPS del emulador en alrededor de un 10%
-
-
Pruebas
- La implementación inicial de la CPU consistía en ejecutar la ROM de Tetris e implementar cada instrucción a medida que se llegaba a un opcode no implementado
-
- match opcode with
- | 0x00 -> Nop
- | _ -> failwith "Unimplemented opcode"
-
- Este enfoque obligaba a saltar aleatoriamente por la documentación técnica, así que la repetición se volvía tediosa y además era difícil saber si una instrucción estaba implementada correctamente
- Para resolver ambos problemas, introdujo pruebas unitarias
- Escribió el código del emulador por su cuenta para aprender, pero usó IA para generar los casos de prueba
- Puso en el prompt las especificaciones de la documentación técnica y pidió que se escribieran pruebas basadas en la especificación sin mirar el código del emulador
- Mientras la IA generaba las pruebas, él mismo leía la especificación e implementaba la lógica hasta que las pruebas pasaran, haciendo desarrollo guiado por pruebas de verdad
- Las pruebas también detectaron algunos bugs en instrucciones que ya había implementado
- Revisó y mejoró las pruebas con regularidad, y estas le ayudaron a dedicar energía a las partes interesantes en lugar de entorpecer el aprendizaje
Componentes después de la CPU
-
PPU
- La Game Boy no tiene una GPU sino una PPU, es decir, una picture processing unit
- En muchos otros artículos sobre creación de emuladores de Game Boy, se concentran en la CPU y solo dedican unos pocos párrafos a la PPU, pero en Fame Boy tomó más tiempo entender la PPU
- La CPU se sintió natural gracias a la experiencia con From NAND to Tetris y CHIP-8, pero la PPU se parecía más a una tarea mecánica de seguir pasos para poner píxeles en pantalla
- Al principio, en vez de intentar entender de una sola vez el FIFO de píxeles y toda la canalización de la PPU, se empezó leyendo y parseando tiles y mapas de fondo desde memoria para mostrarlos en pantalla
- Con este enfoque se pudo ver a la CPU en funcionamiento y, gracias a la simplicidad de Tetris, se obtuvo un resultado que se veía casi como un juego real de Game Boy
- El enfoque de empezar con tiles y vistas de fondo siguió ayudando, desde implementar la pantalla real hasta depurar errores detallados en los datos de sprites
- La PPU de Fame Boy tiene una gran inexactitud respecto al hardware
- La Game Boy real usa una cola FIFO como un monitor CRT para colocar los píxeles en pantalla uno por uno
- Fame Boy renderiza toda la scanline al inicio del periodo de dibujo de esa línea
- Este enfoque es más rápido y el código es más simple, y como todos los juegos que se querían jugar funcionaban, no se sintió la necesidad de pasar a una cola de píxeles
- Los juegos que llevan el hardware de la Game Boy al límite y aprovechan la temporización de la cola de píxeles no funcionan bien en Fame Boy, pero como la mayoría de los juegos no usan el hardware de forma tan agresiva, en general parece que sí funcionan
-
Joypad
- Además de la PPU y la APU, también se trabajó en el joypad
- La implementación inicial fue muy fácil y escribir pruebas también fue sencillo
- Pero después de una gran refactorización casi siempre se rompía
- Como el registro de hardware del joypad es leído y escrito tanto por la CPU como por el juego, la interacción es compleja
- Al principio se hizo que la CPU escribiera el estado del joypad en el registro en cada ciclo, pero como una persona no cambia botones millones de veces por segundo, se cambió para actualizarlo solo una vez por frame
- El resultado fue que la cruceta dejó de funcionar
- El hardware de la Game Boy solo puede leer la mitad de los botones a la vez, y los juegos casi siempre leen el registro del joypad dos o más veces en intervalos cortos y dependen de que el registro cambie entre una lectura y otra
- Un registro almacenado en caché una vez por frame no cambiaba entre dos lecturas, así que la mitad de los botones no funcionaba
- Al final, se implementó para que
IoControlleractualizara el registro del joypad solo cuando la CPU lo lee - Se puede ver más sobre esto en la documentación de joypad de Pandocs
-
Sonido
- Después de crear un emulador funcional, al jugar la versión web se sintió que sin sonido se veía vacía, así que se agregó la APU, es decir, la audio processing unit
- Se descubrió que varios emuladores se ejecutan con la tasa de muestreo de audio del frontend, no con el framerate
- Al principio eso pareció al revés, así que se investigó una tasa de muestreo dinámica y se intentó implementarlo para que el framerate impulsara al emulador
- El sonido fue conceptualmente el componente más difícil, y tomó tiempo entender el comportamiento de varios registros de sonido y canales
- En esta parte la IA ayudó mucho como si fuera un profesor, con varias rondas de preguntas y respuestas antes de programar
- Igual que con la PPU, fue muy satisfactorio ir completando los canales uno por uno, y al escuchar cómo la música de Tetris se iba enriqueciendo poco a poco también se entendió cómo estaba compuesta
- La CPU y la PPU tienen la forma de realizar exactamente X cantidad de trabajo por frame, y X se puede calcular con facilidad, pero en la APU había muchos valores que elegir y ajustar
- Solo la tasa de muestreo de la APU se decidió con facilidad
- La APU real de la Game Boy es flexible, así que el emulador puede usar la tasa de muestreo que quiera
- Fame Boy eligió 32768Hz
- Con un reloj de CPU de 1048576Hz, 32768Hz equivale a 1 muestra cada 128 ciclos de CPU, así que el estado de la APU puede sincronizarse perfectamente usando solo enteros
- Como 128 también es divisible entre 4, incluso si las etapas de la APU se procesan en lotes de 4, no se pierde alineación con las instrucciones de la CPU
- Los demás valores fueron mucho más inestables y, como no se era ingeniero de sonido, hubo que ajustarlos probando cambios
- Había problemas propios de cada frontend y de cada plataforma
- En PC el sonido funcionaba bien, pero en una MacBook sonaba como una cascada
- Al corregir el problema de la MacBook, la versión para PC de escritorio dejó de ejecutarse por una condición de carrera
- Se abandonó el intento de resolverlo de forma inteligente con una tasa de muestreo dinámica, y al cambiar a un modelo donde el audio impulsa al emulador, el audio se volvió mucho más estable en varios dispositivos
- El audio es la parte con más fugas en la interfaz entre el emulador y el frontend, pero para evitar disonancias se necesita una sincronización precisa
Cómo se impulsa el emulador
- La diferencia entre un modelo impulsado por audio y uno impulsado por frames está relacionada con la percepción humana
- Si la señal de audio se corta, las bocinas se mueven bruscamente por el cambio repentino de señal y se produce un ruido pop
- Si el video se corta, el reproductor de video se salta uno o dos frames porque los datos no llegaron a tiempo, pero como no está empujando nada físico, sensorialmente molesta menos
- Dentro de Fame Boy, el audio y el video están perfectamente sincronizados por diseño
- Pero el audio y el video de la computadora donde se ejecuta son independientes, y a veces uno de los dos puede quedarse atrás
- Si el audio y el video del frontend se desalinean, hay dos opciones
- Sincronizar el audio del frontend con el audio del emulador y dejar caer frames de vez en cuando
- Sincronizar el video del frontend con los frames del emulador y descartar audio de vez en cuando
- La parte elegida es la que “impulsa” el emulador, y la otra se mantiene lo más cerca posible
- El modelo impulsado por framerate es relativamente simple
let mutable cycles = 0
while (runEmulator) do
cycles <- cycles + targetCyclesPerMs * lastFrameTime
while cycles > 0 do
let cyclesTaken = stepEmulator ()
cycles <- cycles - cyclesTaken
draw ppu.framebuffer
- El modelo impulsado por sonido es más complicado porque Raylib y Web Audio manejan el procesamiento de audio de forma diferente
- El flujo general es el siguiente
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
- La diferencia clave es que
stepEmulatorya no se controla conlastFrameTime, sino que se ejecuta según lo que necesite el búfer de audio del frontend samplesNeededtiene que calcular cuántas veces llamar astepEmulatorpara ajustarse a distintas tasas de muestreo y poder producir 60FPS- Como el búfer de audio del frontend solo se preocupa por llenarse, puede llamar a
stepEmulatordemasiadas o muy pocas veces por frame, y como resultadoframebufferpodría no actualizarse a tiempo - En el frontend web se puede probar la versión impulsada por frames agregando ?frame-driven a la URL
- La versión impulsada por frames se ve más fluida visualmente, pero a veces produce pops de audio
- El frontend web impulsado por audio también cambia a modo basado en frames si se presiona el botón de silencio, porque así no se oyen los pops
- La implementación no es perfecta, pero como los pops de audio dejan peor impresión que los tirones de frames y el estado en silencio se sentía vacío, se dejó como predeterminado el modo impulsado por audio en el frontend web
- El audio es una de las pocas áreas de Fame Boy con las que no se quedó satisfecho, y es una parte que le gustaría volver a revisar algún día
Publicarlo en la web con Fable
- Después de que el PPU empezó a funcionar más o menos y ya se veía algo en la pantalla de escritorio, quiso llevar Fame Boy a la web
- Revisó la documentación de Fable, instaló los paquetes, configuró el bucle principal y agregó estilos, y en una o dos horas ya tenía todo listo para ejecutarse
- La primera versión con Fable mostraba la pantalla de forma extraña, y tras depurar un poco decidió probar WebAssembly de Blazor para no seguir gastando demasiado tiempo
- Blazor también fue fácil de poner en marcha y esta vez sí funcionó de verdad, pero corría a unos 8 FPS, así que era casi injugable
- No está claro si era un problema de Blazor en sí; también siguió la guía de rendimiento del equipo de .NET, pero no ayudó
- Como depurar ahí también era incómodo, volvió a Fable para revisar qué estaba saliendo mal en el proceso de conversión a JavaScript
- Fable deja el archivo JS convertido justo al lado del código fuente, y de hecho era bastante fácil de leer
- Gracias a eso fue más sencillo entender el código nuevo y depurarlo en las herramientas de desarrollador del navegador
- En las herramientas de desarrollador descubrió que los valores de los registros del CPU eran extraños
- Los registros del CPU de Fame Boy y del Game Boy son enteros sin signo de 8 bits, así que su rango debería ser 0–255
- Sin embargo, aparecían valores como
-15565461
- En la documentación de Fable encontró el documento de compatibilidad de tipos numéricos
(non-standard) Bitwise operations for 16 bit and 8 bit integers use the underlying JavaScript 32 bit bitwise semantics. Results are not truncated as expected, and shift operands are not masked to fit the data type.
- Eso coincidía exactamente con la explicación de que las operaciones bit a bit sobre enteros de 16 y 8 bits usan la semántica de operaciones bit a bit de 32 bits de JavaScript, y que los resultados no se truncan como se espera
- Después de ubicar en el código los puntos donde los valores de 8 bits debían truncarse y corregir los problemas relacionados, el frontend web empezó a funcionar correctamente
- Como usa solo JS y no el runtime de .NET, el bundle web pesa alrededor de 100 KB
- Salvo por ese extraño problema con
uint8, la experiencia usando Fable fue bastante agradable y permitió mantener todo el código fuente en F#
Mejora de rendimiento
- Después de que empezaron a verse resultados en pantalla, agregó un simple log de FPS en consola
- Al principio estaba en unos 55–60 FPS en modo debug, aparentemente porque Raylib intentaba mantener el v-sync
- Al desactivar el v-sync subió a unos 70 FPS, pero apareció jitter
- Después, a medida que se añadían funciones, el rendimiento fue bajando gradualmente hasta llegar a 45 FPS, y desactivar el v-sync ya no ayudaba
- Al ejecutar el profiler de JetBrains Rider,
mapAddressapareció como un cuello de botella sospechoso - Como casi todos los componentes acceden a memoria, confirmó que el costo de acceso a memoria era mayor de lo esperado
- El código problemático leía y escribía memoria mapeando primero la dirección a la unión discriminada
MemoryRegion
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
- Intentó extender al manejo de memoria el flujo que había obtenido al modelar el dominio del CPU, y como resultado cada lectura y escritura de memoria creaba y mapeaba un objeto
MemoryRegion - Ese enfoque asignaba millones de objetos por segundo en el heap y además aumentaba la cantidad de ramas que el compilador JIT tenía que procesar
- Con un solo cambio, eliminó la unión discriminada y la función de mapeo para acceder directamente a los arreglos, y los FPS se duplicaron
- Más adelante, en benchmarks, pareció que la mayor parte de la mejora vino de optimizaciones del JIT sobre ramas y sitios de llamada localizados
- Incluso al cambiar
MemoryRegiona una struct DU para que se asignara en el stack, el rendimiento solo mejoró alrededor de 15%; el otro 85% vino de eliminar la DU y la función de mapeo - Después hubo más casos en los que migró a struct DU o adoptó enfoques poco amigables para F#
- Desde el momento de implementar el PPU ya fue necesario optimizar, y tuvo que renunciar en cierta medida al estilo idiomático de F#
- Revisando el profiler con regularidad y mejorando el rendimiento poco a poco, logró llegar a unos 120 FPS
- La mayor mejora de FPS fue desactivar la compilación debug; en modo release subía hasta unos 1000 FPS
- Hasta el final siguió monitoreando y ajustando el rendimiento de forma periódica
Benchmark
- Consideró que mirar solo el número de FPS en consola no era una buena forma de medir el rendimiento, así que a mitad del proyecto agregó un proyecto con BenchmarkDotNet para medir el rendimiento en escritorio
- Después creó un sencillo benchmark web usando Node.js para estimar de forma parecida el rendimiento en navegador
- Para probar escenarios realistas, los benchmarks usaron las siguientes ROM demo
- El rendimiento de FPS en escritorio en una PC Windows con Ryzen 9 7900 y una MacBook Air con M4 fue el siguiente
| CPU | Flag | Roboto | Merken |
|---|---|---|---|
| Ryzen 9 7900 | 1785 | 1943 | 1422 |
| Apple M4 | 1907 | 2508 | 1700 |
- El rendimiento web en FPS fue el siguiente
| CPU | Flag | Roboto | Merken |
|---|---|---|---|
| Ryzen 9 7900 | 646 | 883 | 892 |
| Apple M4 | 779 | 976 | 972 |
- Fame Boy funciona decentemente en ambas plataformas
- Contra lo esperado, el APU, es decir, el sonido, afecta más al rendimiento del emulador que el PPU
- Si se desactiva el PPU, el rendimiento en escritorio aumenta unos 250 FPS, pero si se desactiva el APU aumenta unos 500 FPS
Uso de IA
- Consideró que incluso en un proyecto de aprendizaje no se puede evitar por completo la influencia de la IA, así que dejó constancia de forma transparente de cómo la usó
- A lo largo de todo el proceso, la IA se usó principalmente como herramienta de apoyo
- para pedir revisión de código
- como interlocutor para evaluar ideas
- para interpretar documentación técnica de forma concisa
- Intentó reducir al mínimo el código escrito por IA
- Como quería crear un resultado que pudiera mostrarle a otras personas y del que pudiera sentirse orgulloso, quiso que quedara como código hecho por él mismo y no como algo basado solo en compartir prompts
-
PR de mejora de rendimiento
- Hacia la parte final del proyecto, le pasó el repositorio al CLI y le pidió que buscara mejoras de rendimiento
- Le dio algunas ideas y también dejó que probara lo que quisiera; en algunos benchmarks logró más que duplicar el rendimiento
- Los detalles están en el PR
- Aun así, también se introdujeron bugs y tuvo que encontrarlos y corregirlos por su cuenta
- Una de las grandes mejoras de rendimiento, “actualizar STAT solo al cambiar de mode/LY”, rompía algunos juegos y demos que dependen de actualizaciones más frecuentes, y lo corrigió con este commit
-
“invierno del temporizador”
-
En el historial de Git hay un gran hueco, y a ese periodo lo llamó “timer winter”
-
No es que no estuviera trabajando en el emulador, sino que estaba bloqueado por un bug que no le permitía pasar de la pantalla de copyright de Tetris
-
Pasó más de 20 horas depurando, buscando en el Discord de emu-dev, creando pruebas y hasta planteando el problema a modelos tempranos de IA, pero no logró resolverlo
-
Después de descansar unas semanas, probó Claude Opus y encontró el problema en cuestión de minutos
-
El problema era que el temporizador solo hacía tick una vez por instrucción, en vez de hacerlo según la cantidad de ciclos que consumía la instrucción
-
-
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 los ciclos de CPU pueden variar entre 1 y 6, en la implementación anterior el temporizador funcionaba en promedio entre 2 y 3 veces más lento de lo real
-
La pantalla de copyright simplemente se quedaba más tiempo de lo debido; el problema fue no haber esperado 1 o 2 minutos
-
El cuerpo principal del texto fue escrito en su mayor parte directamente por él mismo
-
Lo aprendido y conclusión
- El objetivo principal era aprender cómo funciona una computadora, y en ese sentido fue un gran éxito
- El trabajo fue muy divertido y se enganchó tanto que, después de salir del trabajo, empezaba pensando “hoy solo agrego una función” y terminaba a las 2 de la mañana diciendo que iba a corregir un bug más
- Pensó en intentar también con Game Boy Advance, pero al ver las especificaciones le pareció que la ganancia en comprensión del hardware sería de alrededor de 20%, mientras que el esfuerzo requerido sería como 3 veces mayor
- Game Boy ofrecía un buen equilibrio para aprender, y por ahora puede quedarse ahí
- No está seguro de si se volvió mejor ingeniero de software, pero sí que ahora entiende un poco más las herramientas que usa todos los días
- Las preguntas o comentarios se pueden enviar por correo electrónico
1 comentarios
Comentarios de Hacker News
¡Qué gusto ver F# por aquí! Los emuladores son una buena forma de aprender un lenguaje y, a primera vista, parece que eligió bien entre el F# idiomático y el menos idiomático según la tarea
Una mejora fácil para reducir asignaciones sería poner
[<Struct>]en la unión discriminada deInstructions.fs, y reutilizar nombres de campos para que también se reutilicen los campos internosEs un detalle menor, pero parte del manejo de registros confunde un poco. Como ya es tipo
byte, en el setter hacera &&& 0xFFuyno parece agregar nada sobremember val A = 0uy with get, set. Supongo que es un rastro de cambios hechos durante el desarrolloRegisterhay un comentario que dice algo así: los registros no pueden ser un tipo record porque al escribir hay que recortar el valor a 8 bits, así que se necesita un setterLa explicación es que esto pasa por el renderizador web: Fable convierte
uint8aNumberen JS, puede pasarse de 8 bits y no aplica el recorteAsí que, para el objetivo web, parece código conservador para sanear datos por la forma en que Fable ensancha a
Numberde JSPor fin alguien puso esfuerzo humano real para aprender algo, y no es otro “un LLM me ayudó a hacer X en Y minutos”
Supongo que todavía queda un poco de esperanza para la humanidad
Aun así, los emuladores son realmente geniales, y un emulador de GBA es un buen objetivo para intentar por cuenta propia
No daba ninguna sensación de AGI; parecía más bien una máquina de plagio sin el disfraz
Supongo que en algún momento alguien en Microsoft se dio cuenta y activó la alarma de RLHF, así que GPT ha mejorado bastante y en F# parece relativamente utilizable. Tal vez un desarrollador de F# sin principios hoy sí la estaría sacando adelante con agentes
Pero mi sensación no fue “ya resolvieron el problema del plagio, ahora a generar porquerías”, sino “ahora ChatGPT puede plagiar sin que se note tan descaradamente”
Para obtener una ganancia de productividad, no quiero tirar un d100 o un d1000 con la posibilidad de dañar por completo uno de mis valores centrales. Prefiero seguir lento y desempleado. En serio, me estoy moviendo hacia instalación solar y recolección de chatarra
El problema de que “los estudiantes no quieren pensar” es muchísimo más viejo que los LLM. En 2007 llevé un curso avanzado de ecuaciones diferenciales parciales y, como yo sí quería estudiar PDE de verdad, resolví casi toda la tarea, y por ser psicológicamente débil no pude decirles que no a unos flojos maliciosos de matemáticas, así que casi todos copiaron mi tarea. También pasó en posgrado en matemáticas. De verdad es difícil de creer. Si van a hacer eso, no sé para qué están en ese programa
Ah, F#, mi gran amor. Ojalá la gente del lado de C# viera esto y dejara de arruinar C# convirtiéndolo en un lenguaje torpe que intenta hacer de todo
Si haces un proyecto que use C# y F# juntos, no entiendo cómo no ven que así pueden obtener de verdad, y de forma ergonómica, muchas de las cosas que le siguen agregando a C#. La interoperabilidad también es excelente
Puedes llegar bastante lejos usando F# como lenguaje funcional, pero al final quieres interoperar con el ecosistema .NET, y en ese momento terminas programando con un estilo híbrido raro entre orientación a objetos y programación funcional
F# es un buen lenguaje, pero se siente atrapado para siempre en la sombra de C#. Mucho del código de librerías viene heredado de C# y .NET, muchas veces no son interfaces ni librerías diseñadas pensando en F#, y también es común que no haya documentación explícita sobre cómo usarlas desde F#
El problema más grande es que a la comunidad de C# le gusta la orientación a objetos, así que si quieres trabajar de forma funcional muchas veces tienes que envolver esas librerías en algo más “funcional”
Aun así, me parece muchísimo mejor que no tener nada. Me gustan Haskell y OCaml, pero en ese aspecto sí hay comparación
La interoperabilidad con C# afloja algunas garantías de las que normalmente depende el código F#, sobre todo la inmutabilidad. Por la manera en que se mapea a C#, también aparecen limitaciones inesperadas en genéricos
¡Está realmente genial! Me gusta F#, pero después de haber escrito un pequeño intérprete de Smalltalk en F#, sí confirmé que, para este tipo de trabajo, usarlo de la forma “prevista” no lo convierte precisamente en un monstruo de velocidad
Por ejemplo, normalmente me gusta la estructura
Map, y es una estructura inmutable bastante buena para la mayoría de usos. Pero cuando el rendimiento empieza a importar, tampoco cuesta tanto pasar a un loop imperativo aburrido con un hash map normalSi encierras todo dentro de una sola función, en general puedes evitar la sensación de que el código quedó demasiado sucio
Incluso han trabajado en mejorar las tail calls que ni siquiera aprovecha el compilador de C#. Para .NET 9 o 10 también se añadió una característica para que el compilador marque error cuando hay llamadas recursivas en F# que no son tail calls, así se evita romperlo por accidente
Eso sí, si usas listas enlazadas, secuencias y tipos inmutables por todos lados, no va a ser Rust ni de lejos
¡Gran proyecto! Da mucho gusto ver cosas así
Por otro lado, y esto no es un juicio sobre el autor ni sobre el trabajo en sí, ver cómo se ve código F# en un proyecto real me hizo sentir que ya puedo dejar ir mis ganas de aprender y usar F#
La parte puramente funcional es hermosa, pero cuando bajas a código más imperativo o mutable se ve bastante feo. Y por desgracia, en la mayoría de proyectos reales parece que tarde o temprano hay que hacer eso
Así que no sé si debería elegir otro lenguaje funcional y meterme por ahí, o simplemente concentrarme en aplicar conceptos funcionales en el lenguaje que ya uso. Mi lenguaje principal es C# y su soporte para el paradigma funcional sigue creciendo, así que lo segundo es bastante fácil
Un emulador escrito en un lenguaje funcional siempre impresiona. Normalmente es mucho más fácil mapear hardware a un lenguaje imperativo. Da gusto ver qué abstracciones funcionales se le ocurren a la gente
F# es un lenguaje realmente divertido, ¡y excelente trabajo!
F# es el lenguaje de programación que amo pero que jamás puedo usar en el trabajo. Fuera de proyectos personales, no tengo oportunidad de usarlo :(
Es un artículo interesante y agradable de leer. Me gustó la parte de modelado de datos. He estado probando un poco OCaml, y ese tipo de modelado es de lo mejor
También fue interesante descubrir CAMLBOY. Como comentario para el autor, mejor se habría saltado la etapa de edición con IA. Creo que habría preferido errores gramaticales o frases menos pulidas antes que un texto un poco plano como este