Emulador de GB – 21: Precisión

En el post número 6 sobre el emulador (hace un año y medio ya!!) escribí sobre como sincronicé los distintos componentes, y si bien eso que expliqué funciona en la mayoría de los casos, varios juegos tienen problemas relacionados con esa implementación.

Implementación actual

Antes de explicar el problema y la solución quiero hacer un repaso de los datos improtantes de la consola y de mi solución inicial.

En el GameBoy todos los componentes son chips que hacen cosas a partir de un clock y en paralelo. Cada chip hace cosas diferentes (la CPU ejecuta el juego, la PPU dibuja la imagen, etc) y tiene su propia frecuencia, y se afectan entre si a través de interrupciones. La CPU está siempre ejecutando código secuencial hasta que algún componente genera una interrupción, y en ese momento la CPU pasa a ejecutar código especial como respuesta. De esta forma el juego sabe cuando una línea o toda la pantalla se terminó de dibujar, y en esos momentos se puede, por ejemplo, actualizar los tiles del fondo.

Mi implementación cumple con casi todo eso, pero no hice que cada componente funcione en paralelo, lo simplifiqué emulando primero una instrucción de la CPU y después actualizando los demás componentes en base a los ciclos que requiere esa instrucción.

Todas las instrucciones de la CPU requieren una cantidad de ciclos conocida, que es equivalente a decir que cada función tarda cierto tiempo en ejecutarse. Una instrucción que requiere 2 ciclos tarda la mitad de tiempo en ejecutarse que otra que requiere 4.

No importa mucho cuanto tiempo en segundos (o en milésimas de segundo) dura cada ciclo o instrucción, lo mas importante es a cuántos ciclos del clock de los otros componentes equivale. Sabiendo que se ejecutó una instrucción de 3 ciclos en la CPU, yo se que en ese tiempo se tienen que haber ejecutado 12 ciclos del clock de la GPU.

Entonces, mi solución fue:

u8 lastOpCycles = cpu.Step();
gpu.Step(lastOpCycles * 4);
timer.Step(lastOpCycles * 4);
audio.Step(lastOpCycles);
serial.Step(lastOpCycles * 4);

Los componentes claramente no se están actualizando en paralelo, pero en casi todos los juegos funciona bien, ¿por qué en algunos no?

Bug

Es normal que un juego necesite saber cuando se dibujó toda la pantalla, porque el tiempo desde que la GPU dibujó el último pixel hasta que empieza a dibujar el primero del frame siguiente es un montón de tiempo en el que el juego puede modificar la memoria de video. Este tiempo se llama “VBlank” o “Vertical Blank”.

Hay varias formas de escribir código que sepa que empezó el VBlank:

  • Esperar a que 0xFF44 tenga el valor 0x90 (144 en hexa). 0xFF44 siempre tiene el valor de la línea actual que está dibujando la GPU, por lo tanto si dice 0x90 es que la GPU ya dibujó las 144 líneas de la pantalla (0 a 143).
  • Esperar a que 0xFF41 tenga 0b01 en los primeros dos bits. Esos 2 bits de 0xFF41 indican el modo actual de la GPU, y 0b01 es VBlank.
  • Esperar a la interrupción de VBlank. Si se habilita esa interrupción, cuando la GPU inicie la línea 144 va a generar un request.
  • Esperar a la interrupción de LYC. Los juegos pueden configurar una interrupción similar a la de VBlank pero en cualquier línea. Si se configura para la línea 144 va a generarse un request.

Para este bug me interesan las primeras 2 opciones, el juego tiene que leer esas direcciones de memoria y comparar los valores hasta que se cumpla la condición. Por ejemplo:

#1529: LD A,[0xFF44] // se copia el número de línea actual al registro A
#152C: CP A,90 // se compara el registro A con 0x90 y se actualizan los flags
#152E: JR !Z,-7 // si la comparación no prendió el flag de Z se vuelve a #1529

Algunos juegos estaban esperando siempre en estas 3 instrucciones, pero la instrucción de LD en #1529 nunca leía un 0x90.

Debuggeando durante mucho tiempo pude ver que lo que estaba pasando era que después de ejecutar la primer instrucción A valía 0x8F, pero se generaba la interrupción de VBlank. El juego habilitó esa interrupción así que en lugar de ejecutarse la instrucción de CP, se ejecuta el código que responde a la interrupción. Ese código puede durar mucho tiempo, sobre todo si está pensado para funcionar durante el VBlank que se sabe que es mucho tiempo. Mientras la CPU ejecuta ese código, la GPU sigue actualizándose y cambia el valor de 0xFF44 a la línea 0x91, 0x92, etc.

Al terminar de responder la interrupción la ejecución del juego continúa desde donde había dejado (#152C), A en ese momento valía 0x8F así que el loop vuelve para arriba. Ahora al leer el valor de 0xFF44 es 0x93! Como no es 0x90 el loop va a seguir, y cuando vuelva a ser 0x8F en un momento va a haber una interrupción de VBlank y el juego se queda trabado.

Primero pensé que el problema era que la interrupción de VBlank no debería pasar, pero después de debuggear mucho mas y comparar con BGB, entendí el problema…

Paralelo

Acá es cuando importa la ejecución en paralelo. Lo que está pasando en mi implementación es que la GPU se actualiza siempre después de que la CPU leyó 0xFF44, pero en realidad durante esos 2 ciclos de CPU (esa instrucción de LD usa 2 ciclos) la GPU va a ejecutar 8 ciclos, y esos 8 ciclos pueden modificar, por ejemplo, 0xFF44. Si el primer ciclo de la GPU modifica 0xFF44, para cuando la CPU lo lea va a haber un 0x90, y va a poner 0x90 en A. La GPU va a pedir una interrupción por VBlank igual que antes, y la CPU antes de ejecutar la instrucción siguiente va a responder a la interrupción. La GPU va a seguir cambiando 0xFF44 mientras la CPU hace cosas y en algún momento la CPU va a terminar y va volver a donde estaba antes, línea #152C, pero esta vez A ya vale 0x90!! Ya no se bloquea!!

Entonces la solución es ejecutar todos los components en paralelo y sincronizarlos. Empezar varios threads, usar semáforos y…

Pseudo-paralelo

No. Capaz para emuladores mas serios o empezando un emulador de cero se puede hacer algo así, pero en mi caso preferí buscar otras alternativas menos complejas que sean “mas” paralelas que la actual, sin llegar al 100% paralelo de los threads.

Después de intentarlo de varias formas llegué a una solución que me convence, pero antes de explicarla quiero explicar las 2 anteriores fallidas:

Opción 1

En lugar de emular una instrucción de CPU y tomar ese tiempo como referencia, puedo tomar el clock de mayor frecuencia (la GPU) y actualizar todos los componentes por cada uno de esos ciclos.

El problema con esta solución es que hacer eso cuesta un montón, todos los componentes ya funcionan así, salvo la CPU. Hay que replantear toda la arquitectura de como se ejecutan instrucciones porque hay que mantener el estado intermedio de cada instrucción, pero además hay que tener en cuenta el manejo de interrupciones y otros detalles del hardware que lo hacen todavía mas complejas.

Esta solución la intenté hace años cuando quise hacer un emulador de NES y no fue fácil ni me dió buenos resultados.

Opción 2

Actualizar todos los componentes ANTES que la CPU. Para hacer esto tengo que saber cuantos ciclos de CPU se necesitan para la instrucción siguiente sin ejecutarla, después actualizar los demás componentes, y por último ejecutar la instrucción.

Esto claramente tampoco es ejecución en paralelo y probablemente tenga problemas parecidos al que estoy tratando de resolver, pero ni siquiera llegué a probarlo porque también es difícil de implementar.

¿Cuántos ciclos necesita instrucción? Fácil, esa información se encuentra en todos lados, como en esta tabla. Bueno, no es tan fácil, algunas instrucciones usan mas o menos ciclos según los flags de Carry o Negative, entonces una sola tabla o array en memoria ya no alcanza. Es es relativamente fácil de resolver (chequeo el flag correspondiente), pero también hay que tener en cuenta las interrupciones, porque puede que un “paso” de la CPU no sea la instrucción siguiente sino la preparación para responder a una interrupción, que dura unos 7 ciclos. Y además hay un bug en la instrucción de HALT y otro en DI. Para tener en cuenta todo eso habría que ejecutar casi todo el código de actualizar la CPU, pero sin modificar el estado.

Intenté de varias formas, pero entre que era complicado y probablemente causara otros problemas lo descarté.

Opción 3 – La que funcionó!

Al final lo que me funcionó es bastante simple. Como dije mas arriba, sigue siendo “casi” en paralelo pero no realmente, así que en cierta medida tiene el mismo problema que estoy tratando de resolver pero menos grave.

En lugar de actualizar todos los componentes después de cada instrucción, los actualizo después de cada ciclo de CPU.

Podrán estar pensando, “Pero Diego, ¿no dijiste en la opción 1 que para emular la CPU por ciclos tenés que reescribir todo y lo descartaste?”. Pero no, no hace falta.

Ya actualmente en el código estoy sumando ciclos a un contador que tiene la CPU, cada vez que hago algo (como leer un byte de memoria) incremento ese contador en 1. Entonces lo que puedo hacer es actualizar todo lo demás cada vez que incremento ese contador.

Lo mejor de esta solución es que es muy rápida de implementar, solo tuve que mover un par de líneas a una función:

void Update(u8 cycles) {
    gpu.Step(cycles * 4);
    timer.Step(cycles * 4);
    audio.Step(cycles);
    serial.Step(cycles * 4);
}

Y pasarle el objeto que tiene esa función a la CPU para llamar a esa función. Por ejemplo:

void CPU::WriteMemory(u16 address, u8 value) {
    mmu.Write(address, value);
    gameBoy.Update(1);
}

Y con este cambio chico (que me llevó MUCHISIMO TIEMPO entender) unos 15 juegos ahora inician bien, y probablemente otros juegos que hubiesen fallado en alguna parte ahora no van a fallar.

Conclusión

Antes de empezar con el emulador ya sabía que el tema de precisión era complejo, y es importante tener en cuenta cuanta precisión se quiere lograr. Tengo en claro que mi nueva solución tampoco es la ideal, pero al menos está mas cerca. La precisión es algo a tener en cuenta en todos los componentes, no solo en la CPU o en la sincronización, algún dia voy a implementar el “PixelFIFO” de la GPU y voy a escribir un post sobre eso, hay al menos 1 juego que lo necesita.

Otra cosa importante de esta solución es que tiene un impacto en la performance, el código que moví a la función Update antes se llama 1 vez por instrucción y ahora se llama de 1 a 7 veces según lo que haga la CPU. Mas allá de que el emulador funciona bien a 60 FPS, cuando hago pruebas desbloqueo los FPS para optimizar, y pude ver que esto bajó los FPS alrededor de un 7% (de 660 a 620 en mi PC con un juego de GBC). En el próximo post voy a contar como optimicé el emulador para pasar de 620 a 900 FPS en el mismo juego.

Deja un comentario

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión /  Cambiar )

Google photo

Estás comentando usando tu cuenta de Google. Cerrar sesión /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión /  Cambiar )

Conectando a %s

Crea tu sitio web con WordPress.com
Empieza ahora
A %d blogueros les gusta esto: