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.
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:
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.
C y C++ son dos de los lenguajes mas usados en aplicaciones que requieren alto rendimiento. Muchas veces el manejo de memoria manual, la flexibilidad para hacer casi cualquier cosa y la cercanía con el hardware son características necesarios en este tipo de aplicaciones.
Pero no son lenguajes perfectos (si es que existe algo así) y tienen muchas críticas. Muchos de las cosas que se les critican, creo, es porque son lenguajes viejos, que si bien tuvieron y siguen teniendo actualizaciones con features modernas, mantienen compatibilidad con todas las versiones anteriores y eso agrega mucha complejidad. Los lenguajes cambiaron tanto que hoy en día se habla de «Modern C++» como si fuese un lenguaje diferente, pero en la práctica la línea entre C y C++ y entre C++ y «modern» C++ es difusa. En software que lleva años de desarrollo o interactúa con APIs viejas se empieza a mezclar todo. Por ejemplo, para manejar strings existe la forma vieja con strings de C (char* y terminados en «\0») y la forma nueva de C++ (clase std::string), y según que queramos hacer hay que usar una u otra en distintos lugares. E incluso es posible encontrar otras implementaciones de strings en un mismo proyecto, como dato curioso, trabajé en un proyecto en el que habían CINCO tipos de dato para representar strings (C, C++, Objective C, Qt y uno propietario).
Además de las complejidades de mezclar C, C++ y modern C++, los procesos para compilar estos lenguajes también varían un montón. Hay muchos compiladores para elegir y muchas herramientas para integrar todo, así que trabajar con C++ requiere tomar un montón de decisiones antes de empezar que después son dificiles de cambiar, y sumarse a un proyecto ya empezado requiere un montón de preparación del entorno.
Hay muchas mas críticas, como la separación en archivos header y source, el «boilerplate» que hay que escribir para muchas cosas, la cantidad de «undefined behaviour», las macros, los mensajes de error poco claros, etc, etc, pero no quiero hacer un post entero sobre C++.
Alternativas
Desde hace muchos años existen otros lenguajes en desarrollo que, explícita o implícitamente, buscan ser una alternativa a C++. Rust es el mas popular, pero hay mas, como Zig o, en ciertos aspectos, Go.
Los juegos son de esas aplicaciones de alto rendimiento para las que C++ es muy útil, y en general es el lenguaje standard en la industria. Se pueden hacer juegos usando cualquier otro lenguaje, pero incluso en engines en los que se usan otros lenguajes, el engine en si suele estar escrito en C++ y los otros lenguajes se usan para lo que se suele llamar «scripting». Mi idea no es minimizar esos lenguajes ni el trabajo de quienes programamos usando, por ejemplo, C# en Unity; es solo para explicar el contexto y a que me refiero con que C++ es el standard en la industria.
Entonces, si no se quiere o no se puede usar un engine existente, la alternativa es programar uno propio, y las opciones son pocas. Rust y Zig todavía no tienen mucho desarrollo relacionado con gaming, de Rust hay algunas bibliotecas, pero la transición no es tan sencilla por varias decisiones de diseño del lenguaje. Acá es donde aparece Jai.
Jai
Es un lenguaje que están desarrollando en Tekhla Inc. (Braid, The Witness) pensado en principio para hacer juegos sin usar C++, pero con las cosas de C y C++ que los hacen tan necesarios. Hay gente usándolo para otras cosas, pero algunas features que se ven en otros lenguajes capaz no están en Jai porque para los desarrolladores los beneficios al desarrollar juegos no justifican el costo de implementarlas.
Para reforzar la idea de que es una alternativa viable para juegos, en Tekhla no solo están haciendo el lenguaje, también están haciendo un engine y su próximo juego con Jai (sus juegos anteriores tienen su propio engine escrito en C++). El lenguaje está en desarrollo desde hace varios años y actualmente solo está disponible para participantes de una beta cerrada, a la que tengo acceso desde hace unos meses. Al final voy a dejar videos de Jonathan Blow donde explica sus motivos para crear Jai y los features que tiene.
Port
Si bien Jai está siendo diseñado con la idea de hacer juegos, en el fondo sigue siendo un lenguaje compilado de bajo nivel que se puede usar para cualquier tipo de aplicación. Cómo quiero probar el lenguaje pero no tengo juegos propios ni planeo hacer un juego de cero, hice un port de la versión de C++ de mi emulador de GB.
Objetivo
Por el momento mi plan es hacer un port «1 a 1», mas adelante voy a repensar y refactorizar las cosas para usar features de Jai y probablemente escriba sobre eso en otro post.
Por ahora quiero portar todo lo mas parecido posible a lo que hice en C++ para hacerlo funcionar, aunque no esté aprovechando ciertas cosas. En general lo que hice fue copiar el código y traducirlo, no estoy reimplementando el emulador de cero.
El resto del post va a ser una explicación de algunas cosas que tuve que tener en cuenta con este lenguaje nuevo.
Sintaxis
La sintaxis del lenguaje es diferente a los lenguajes «C-like», y como dato no menor, todavía no está definida y hay detalles que pueden cambiar durante la beta.
Hay diferentes cosas que se pueden definir: funciones, variables, constantes, structs, enums y mas. Todo se define siguiendo reglas parecidas, así que no voy a explicar cada caso porque es algo muy repetitivo y aburrido. Lo importante es que se escriben diferente que en C++, así que todo el código que copio necesita algunos cambios. Por ejemplo, dos función en C++ y en Jai:
// C++ int Func1 (int arg) { return arg; }; void Func2 (string arg) {};
// Jai Func1 :: (arg: int) -> int { return arg; }; Func2 :: (arg: string) {};
Tienen mas o menos las mismas cosas, pero en distinto orden. En Jai el nombre de lo que se define SIEMPRE va primero, y no se usa «void» cuando las funciones no devuelven nada.
Me parece que este orden de definir las cosas es mejor que el de C, porque el nombre de la función/variable/tipo es lo que buscamos en general y es lo primero que leemos.
Programación Orientada a Objetos
Jai no soporta POO, es algo que se definió desde el principio y no va a cambiar, así que cualquier código hecho con POO hay que adaptarlo. Los motivos por los que no se usa son varios, pero no vienen al caso. El emulador lo hice usando POO porque es lo que hago desde hace muchos años, pero usé pocas cosas y en pocos lugares así que la mayoría lo puedo portar sin problemas.
En POO una instancia tiene un conjunto de propiedades y métodos disponibles, por eso se pueden hacer cosas como «player.Health = 100» y «player.Attack()». La asignación es así en casi cualquier paradigma y funciona como cualquiera esperaría en Jai. Para la llamada a la función en POO, internamente lo que está pasando es que se llama a la función «Attack» y se pasa como parámetro, de manera implícita, una referencia a la variable «player», y dentro de la función todos los miembros de player están disponibles (con «this» o implícitos).
Es fácil traducir eso de POO a no-POO: La función se define con un primer parámetro del tipo del player, y en cualquier lugar donde sería válido «this» hay que usar esa referencia:
// C++ void GPU::Step(int emulatedClocks) { clocks += emulatedClocks; // clocks es un miembro de GPU UpdateMode(); // UpdateMode es una función de GPU };
En muchos lenguajes existe esta palabra reservada, pero su significado varía. En Jai se usa para agregar los miembros de un tipo al contexto actual. ¿Qué significa esto? Que en un bloque de código, después de usar «using unaVariable;» las siguientes líneas pueden referirse a miembros de unaVariable sin escribir «unaVariable.» antes. Esto simplifica un poco el código que mostré arriba:
// Jai Step :: (using gpu: *GPU, clocks: int) { clocks += emulatedClocks; // equivalente a gpu.clocks UpdateMode(gpu); // en funciones hay que escribir todo igual };
Polimorfismo, herencia, métodos virtuales
El lenguaje tiene algo similar a polimorfismo pero no tiene nada parecido a herencia de POO. Yo usé herencia para los MBCs y métodos virtuales, así que eso lo tengo que cambiar.
Para polimorfismo hay que usar la misma palabra reservada «using» que nombré antes, capaz en otro post explico mejor como funciona en este contexto.
Herencia implica que una clase derivada de otra tiene todas las propiedades y funciones de la clase padre. En Jai no hay herencia, los tipos están definidos por si solos, pero de nuevo, con la palabra «using» se puede hacer algo similar para las propiedades.
Para reemplazar métodos virtuales por el momento simplemente hice un switch según el tipo de la variable y llamo a distintas funciones que se llaman igual, pero reciben distintos tipos de MBC.
Casteo
Jai tiene muy pocos casteos automáticos, así que muchas cosas que en C++ son implícitas hay que hacerlas explícitas. Por ejemplo, en muchos lenguajes se puede asignar un valor float a una variable int sin problemas. Capaz algunos lenguajes tienen la opción de mostrar un warning, pero es raro que sea un error. En Jai es un error de compilación.
La idea del autocast tan restrictivo es evitar algunos errores difíciles de encontrar. Sólo se hace autocast cuando el tipo de la variable destino es del mismo tipo primitivo e incluye todos los valores del tipo de origen. Por ejemplo:
a : u8 = 156; // entero de 8 bits, sin signo b : u16 = a; // entero de 16 bits, sin signo, funciona c : s8 = a; // entero de 8 bits, con signo, no funciona d : s16 = a; // entero de 16 bits, con signo, funciona e : float = a; // flotante de 64 bits, con signo, no funciona
«c» no funciona porque la variable «a» puede tener el valor 255, pero «c» al ser de 8 bits con signo solo permite valores entre -128 y 127.
«e» no funciona porque es de tipo con punto flotante y «a» es de tipo entero, aunque un float puede representar los valores del 0 al 255 sin problemas.
En el emulador usé tipos similares a esos en todos lados, así que tuve que revisar muchos casos de estos.
En otro post voy a hablar mas en detalle sobre casteo porque hay muchas cosas a tener en cuenta y es lo que me trajo mas problemas.
Arrays
Jai tiene varios tipos de array:
Tamaño fijo (ej: a : [3]int;). El temaño se define con una constante durante la compilación.
Vistas (ej: b : []int = a;). Se crean a partir de otros arrays en tiempo de ejecución.
Tamaño variable (ej: c : [..]int;)
Los 3 tipos tienen siempre un miembro «count» con la cantidad de elementos del array, y un miembro «data» que es un puntero al primer elemento.
En C++ definir arrays de esta forma (int[]) es la forma «vieja» de C, que es bastante limitada: no tiene información de la cantidad de elementos y solo existen de tamaño fijo. La alternativa de C++ es usar std::array o std::vector que son clases, pero como dije antes, C y C++ se mezclan. En el emulador usé muchos arrays de C porque es mucho más cómodo escribir «u8[256]» que «std::array<u8,256>».
Cómo el emulador tiene mas que nada arrays de tamaño fijo no tuve que hacer muchos cambios, solo editarlo para que tenga la sintaxis correcta.
Una cosa importantísima de los arrays de Jai es que por defecto hacen un «bounds check», es decir, al momento de acceder a un elemento del array en cierto índice, si el índice no es válido (mayor o igual a 0 y menor a .count) el programa crashea con un mensaje de error muy descriptivo. En los arrays de C hay que hacer este chequeo a mano, porque el acceso a índices inválidos «funciona» y termina modificando memoria fuera del array. Estos problemas son muy difíciles de detectar, porque no traen consecuencias inmediatas, sólo trae problemas al querer acceder a esa otra variable que se modificó sin saberlo.
El chequeo se puede deshabilitar para mejorar la performance, pero en las pruebas que hice no hizo mucha diferencia así que lo dejé activado.
Valor inicial
Una diferencia chica pero importante es qué valor tienen las variables si se las define sin asignarles nada. En C++ es «basura», lo que sea que haya en la memoria donde se creó esa variable. Esto trae muchos problemas, sobre todo cuando recién se empieza a programar en C++, porque punteros sin inicializar apuntan a cualquier lado. Estos errores son difíciles de encontrar sin alguna herramienta que haga análisis estático de código, y además no siempre traen problemas evidentes.
En Jai todas la variables se inicializan por defecto en 0 o el equivalente de 0 (ej: un string está vacío, un puntero es null, un booleano es false), similar a otros lenguajes como C#. Es posible crear una variable sin inicializar para evitar esa operación cuando se sabe que el valor se va a asignar mas tarde y no se va a acceder hasta ese momento.
a : int = 0; // es lo mismo que a : int; b : float = 2.0; // si no se pone 2.0 se inicializa en 0.0, los floats van sin "f"!! c : s16 = ---; // se crea la variable sin valor inicial, puede tener cualquier cosa
Es algo chico, pero que permite borrar un montón de » = 0″, «= false», «= null», etc, cosas que uno se acostumbra a poner en C++.
Documentación
Por ahora el lenguaje y el compilador están en beta, y tiene poca documentación. Hay varios «how to» incluidos en el proyecto, pero varias cosas requieren mirar la implementación para entenderlas mejor y para saber que funciones están disponibles.
Por ejemplo, en una parte del emulador tengo que separar el path al rom en carpeta, nombre del archivo y extensión. Mi implementación en C++ era hacerlo a mano y así empecé a hacerlo en Jai, hasta que revisando el módulo de strings encontré que ya existen funciones para esto.
Bindings
Bindings se le dice a el código que hay que escribir en un lenguaje para poder usar dlls o funciones del sistema.
Junto con el compilador y la documentación que se nos envío ya hay bindings y módulos con un montón de funcionalidad, e incluso juegos simples de ejemplo. Hay cosas como bindings para distintas APIs gráficas, modulos para abrir archivos y de encriptación. En la versión de C++ usé SFML, pero en lo que trajo lo mas parecido son bindings con SDL2, así que la versión de Jai la adapté para usar SDL2. Esto requiere reescribir algunas cosas, pero me pareció mas simple que aprender a configurar bindings para SFML.
IDE
Por ahora estoy usando una mezcla, hay un plugin para Visual Studio Code para tener colores en el código, para compilar uso la consola/terminal (desde VSCode se puede abrir una), y para debuggear uso Visual Studio. Abriendo el proyecto con el .exe ya compilado al darle play se carga el PDB automáticamente, y se pueden poner breakpoints o inspeccionar variables sin problemas.
Beta
Es posible encontrar bugs en el compilador o inconsistencias en el lenguaje, y hay cosas que pueden cambiar de una versión a otra, así que hay que prestar atención a los logs en cada release. Lo bueno es que hay muchos ejemplos incluidos y hay un Discord para la beta donde otros usuarios están compartiendo cosas útiles.
Código
No quiero escribir sobre cada cosa que hice porque es muy repetitivo, pero dejo acá el link al repositorio con el código: https://github.com/DiegoSLTS/gb-jai
Conclusión
El proceso del port es similar a un port entre cualquier par de lenguajes, aunque es interesante pasar de un lenguaje a otro que se plantea explícitamente como una alternativa/reemplazo. Está bueno ver que herramientas tiene cada lenguaje para solucionar un problema, incluso pude arreglar varios crashes que en C++ me costaba mucho encontrar gracias a cosas que Jai hace diferente.
Este post va a ser mas corto que el anterior porque no hay muchas decisiones para tomar. La idea es explicar que pasa con las texturas al hacer el build y al ejecutar el juego, usando las herramientas que da Unity.
Build
Del proceso de build no hay tanto para hablar. Ya están todas las imágenes configuradas con la compresión elegida y hay que hacer un build. No estoy seguro si Unity vuelve a procesarlas o si usa directamente los archivos de la carpeta Library, pero lo importante es que se va a comprimir en el formato que tiene configurado.
En Android (y capaz en otras plataformas) hay una excepción, porque existe un override global que se puede usar para texturas que no tienen un override configurado:
Para ver cuánto espacio del build ocupa una imagen (o cualquier otro archivo) se pueden ver los logs del proceso de build una vez que terminó abriendo los logs del editor:
Click derecho sobre la pestaña «Console»
En mi caso hice un build para Android con una escena que tiene la imagen negra con el círculo blanco en el centro del post anterior («plano_260x180»), con la compresión que funcionaba bien (izquierda).
En la captura se ve un tamaño de 45.7KB, y en los logs del editor:
Build Report
Uncompressed usage by category (Percentages based on user generated assets only):
Textures 45.8 kb 0.3%
Meshes 0.0 kb 0.0%
Animations 0.0 kb 0.0%
Sounds 0.0 kb 0.0%
Shaders 17.9 kb 0.1%
Other Assets 1.2 kb 0.0%
Levels 5.4 kb 0.0%
Scripts 717.8 kb 4.9%
Included DLLs 13.4 mb 94.5%
File headers 14.2 kb 0.1%
Total User Assets 14.2 mb 100.0%
Complete build size 80.9 mb
Used Assets and files from the Resources folder, sorted by uncompressed size:
46.3 kb 0.1% Assets/Texturas/plano_260x180.png
18.5 kb 0.0% Resources/unity_builtin_extra
En el acumulado «Textures» se ve que hay 45.8 KB de texturas, 1KB mas que probablemente sea una cuestión de redondeo al mostrar el valor.
En el detalle de cada textura el número es un poco mayor… No encontré información sobre esto, pero comparé varias texturas y al parecer siempre tienen alrededor de 0.5KB de mas, que me imagino que tienen que ver con metadatos (ej: los otros settings de importación).
Por mas que en varios lados diga «Uncompressed», las texturas están comprimidas. A lo que se refiere ese «uncompressed» es a que el archivo final (el .apk en el caso de Android) se comprime con un algoritmo mas genérico de compresión de archivos, y los porcentajes reales podrían variar.
Ejecución
Llegó el momento de ejecutar el juego, pero de nuevo hay un par de cosas a tener en cuenta. Por un lado, está bueno entender que hace Unity, pero además varias veces dije que algunas GPUs no soportan ciertos formatos de compresión, y acá es cuando eso se vuelve importante y hay que entender las consecuencias.
Viendo la tabla de formatos en la documentación, en Linux y consolas todos los formatos de compresión disponibles están soportados. En esos caso lo que hace Unity es:
Lee el archivo con el contenido de la imagen
La mantiene en memoria RAM en el formato que lo leyó
La copia así como está a la memoria de video
La GPU accede a la imagen para tomar muestras al momento de dibujar
En Windows casi todos los formatos están soportados, pero en mobile y web es donde hay que tener mas cuidado y donde es mas probable que aparezcan problemas al ejecutar el juego, ya que tiene mas limitaciones. En Adroid, además, hay una variedad de hardware enorme comparando otras plataformas.
¿Cómo se si mi dispositivo soporta un formato?
En las tablas de la documentación está toda la información sobre formatos generales, formatos para mobile/web y formatos específicos de Android o iOS. Para cada formato que tenga soporte parcial («Partial(X)» en la tabla) abajo se explica que condiciones tiene que cumplir el dispositivo, así que es algo que hay que validar en cada caso.
Por ejemplo, «ETC1» en Web dice que lo soportan solo algunos navegadores (no encontré una lista). El mismo formato ETC1 funciona en todos los dispositivos Android, pero ETC2 solo en los que tengan OpenGL 3.0. En Windows «DXT1» funciona siempre, pero el mismo formato en Android solo funciona con GPUs Tegra (nVidia Shield).
Realmente no tiene sentido que me ponga a repetir toda la información, yo se que es mucha y que al estar sólo en inglés puede ser mas complicado para mucha gente, pero son cosas que hay que revisar y tratar de entender si se quiere hacer un juego que funcione bien en el target de dispositivos. No hay ninguna obligación de soportar TODOS los dispositivos, pero es una decisión que hay que tomar en algún momento.
¿Qué pasa si corro mi juego en un dispositivo que no soporta ese formato?
Lo primero que hay que tener en cuenta es que el juego igual va a funcionar, al menos en la mayoría de los casos. El problema es que aunque el juego funcione hay una consecuencia, pero no va a ser un mensaje de error en la pantalla y puede pasar desapercibido muy fácil.
Unity comprime las texturas con la configuración elegida al hacer el build, no toma ninguna decisión extra.
Cuando carga la textura en el dispositivo primero hace un chequeo para saber si le GPU soporta imágenes en ese formato. Si la GPU la soporta no hay nada mas que hacer, sigue los pasos que enumeré antes. Pero si la GPU NO soporta el formato, Unity descomprime la imagen en memoria RAM y la usa descomprimida o la recomprime en otro formato. Esto afecta la performance del juego de 3 formas:
Se tarda mas en cargar una textura porque hay que convertirla
Se usa mas RAM porque, incluso si se recomprime, el formato probablemente no sea tan avanazado
Se usa mas memoria de video, por el mismo motivo
Esto último es muy importante. Es natural pensar que al usar formatos de compresión mejores y reducir el tamaño del build el juego va a funcionar mejor en dispositivos de gama media o baja, pero si no se tiene en cuenta el soporte de estos formatos podemos estar emporando la performance justamente en los dispositivos que mas necesitan optimización.
El nuevo formato de la imagen depende de cada plataforma y de distintos factores. Está casi todo explicado en la documentación, pero por ejemplo: En Windows el formato «RGB(A) Compressed BC7» solo funciona a partir de OpenGL 4, DirectX 11 y Metal, si la GPU no es compatible la textura se pasa al formato RGBA32 (sin compresión, 4 veces mas uso de RAM). En Android ETC2 solo funciona a partir de OpenGL 3, en teléfonos con OpenGL 2 se cambia el formato al configurado como «ETC2 Fallback» en los build settings.
A lo que voy con esto es que es importante, siempre que sea posible, probar con dispositivos de distintas características si planeamos soportarlos.
Para confirmar que está funcionando correctamente se puede ver el uso de RAM en el profiler de Unity conectado al dispositivo. Las siguientes capturas son del build que mostré antes de Android, corriendo en mi teléfono:
La primer captura es comprimiendo en ETC2, un formato que mi teléfono soporta, y se puede ver que está usando 45.7KB de RAM.
La segunda captura es la misma escena, pero con la textura comprimida con DXT1, que mi teléfono no soporta porque no tiene GPU Tegra. Al comprimir en Unity con ese formato la visa previa muestra una textura de 22KB, pero al correr el juego en mi teléfono la textura está usando 182.8KB. No tengo un dispositivo con Tegra, pero en ese caso el profiler mostraría una textura de 22KB.
Si por alguna razón usar el profiler no es una opción, también se puede detectar el problema viendo los logs al reproducir el juego. No da información de que textura tiene el problema, pero se puede ver un warning que dice que el formato de la textura no está soportado y que la va a descomprimir (3er imagen).
En estos casos conozco 2 opciones:
Usar un formato con mayor compatibilidad y probablemente menor compresión para todos dispositivos
Generar diferentes builds para distintas GPUs con la mejor configuración que soporten.
La primer opción es la mas cómoda para mi y es la que usé cuando hacía juegos mobile, pero la segunda opción es la que permite aprovechar mejor el hardware. Esta opción es mas avanzada en muchos sentidos:
En general el proceso de build está automatizado así que hay que adaptarlo para generar varios archivos
Cada plataforma (no todas) tiene su manera de especificar los dispositivos compatibles con un build (ej, en Android se puede filtrar por capacidades de hardware y tipos de GPU en el manifest)
La publicación en los stores también se hace mas compleja si se trabajan con builds para distintos segmentos
No voy a explicar como hacer todas esas cosas porque lo investigué hace años y ya no se que tan válido es todo, pero si realmente quieren aprovechar al máximo cada dispositivo aconsejo buscar mas información sobre esto.
Android y ETC
Quiero hablar un poco sobre el formato ETC1 en Android porque me parece importante.
Viendo la documentación, ETC es el único formato con compresión que soportan TODOS los dispositivos Android, porque es compatible con OpenGL 2.0. Según estas estadísticas de Google, en Mayo de 2020 un 14.5% de los dispositivos Android sólo soportan OpenGL 2.0, así que aunque la gran mayoría (75.5%) soporta versiones superiores, todavía es un segmento de usuarios que pueden llegar a jugar el juego.
Si usan este formato no van a tener los problemas que comenté en la sección anterior, pero como siempre, no todo es tan fácil.
Lo mas importante a tener en cuenta es que ETC sólo funciona con potencias de 2 y sólo trabaja con RGB, NO soporta canal Alpha.
Esto en principio limita mucho el uso de ETC, probablemente no podría usarse en sprites ni en interfáces… pero hay una solución y no es muy complicada.
En primer lugar, lo de potencia de 2 ya lo hablé en el post anterior, pero se puede resolver usando spritesheets (o Sprite Atlas como se llama en Unity).
Y para la transparencia la solución es 1 click. Al importar una textura en formato ETC se puede activar una opción de «Split Alpha Channel», y la misma opción aparece al elegir el formato de compresión en el Atlas.
Sprite atlas con una sola imagen, de un tamaño mayor a la imagen original pero potencia de 2.
Unity va a separar el canal A de la textura original, va a comprimir por un lado los canales RGB usando ETC, y va a generar una textura paralela, también con canales RGB y comprimida con ETC, pero usando el valor del canal A para R, G y B.
Internamente Unity va a guardar las dos texturas al hacer el build, va a cargar las 2 en ram durante la ejecución, va a copiarlas a la memoria de video y va a usarlas en conjunto para decidir el color y la opacidad de un pixel al dibujar el objeto en la pantalla. Como este proceso genera una segunda imagen, es importante deshabilitar la opción si la imagen original no tiene transparencias.
La textura generada para el alpha se puede ver acá:
Cómo se puede ver en las capturas, son dos texturas, cada una de 16KB.
En los logs del build se puede ver el atlas:
Build Report
Uncompressed usage by category (Percentages based on user generated assets only):
Textures 32.4 kb 0.2%
Meshes 0.0 kb 0.0%
Animations 0.0 kb 0.0%
Sounds 0.0 kb 0.0%
Shaders 17.9 kb 0.1%
Other Assets 1.4 kb 0.0%
Levels 5.4 kb 0.0%
Scripts 809.8 kb 5.5%
Included DLLs 13.4 mb 94.0%
File headers 14.2 kb 0.1%
Total User Assets 14.3 mb 100.0%
Complete build size 120.1 mb
Used Assets and files from the Resources folder, sorted by uncompressed size:
18.5 kb 0.0% Resources/unity_builtin_extra
16.2 kb 0.0% Built-in Texture2D: sactx-0-256x128-ETC1-New Sprite Atlas-caa4a579_alpha
16.2 kb 0.0% Built-in Texture2D: sactx-0-256x128-ETC1-New Sprite Atlas-caa4a579
0.5 kb 0.0% Assets/Texturas/triangulo.png
0.2 kb 0.0% Assets/New Sprite Atlas.spriteatlas
Un par de comentarios sobre ese log:
Las dos texturas del atlas están ahí, cada una de 16Kb. El nombre es raro porque son archivos autogenerados por Unity
El asset del atlas está mas abajo, 2KB
Una línea tiene la textura «triangulo.png», que es la que usé dentro del Atlas, pero no está importando la textura real, esos 0.5KB son metadatos de la textura.
Y en el profiler también se puede ver que se están usando 2 texturas del atlas de 16KB cada una y NO está la de triangulo.png:
Algo también muy bueno es que esta técnica funciona para sprites 2D y para uGUI, cualquier Image u otro componente que use un sprite puede usar uno configurado de esta manera. Hace unos años la segunda textura solo se usaba en sprites 2D y en uGUI había que hacer un shader personalizado para leer la textura con alpha, o usar otro formato.
Conclusión
Acá termina la segunda parte y todo lo que quería decir sobre importación de texturas en Unity. Seguramente los conceptos apliquen a otros engines porque todos en definitiva tienen que trabajar con el mismo hardware y de ahí es de donde surgen todas estas limitaciones a considerar.
No es un tema sencillo, pero con el esfuerzo que lleva entender, configurar y probar cada configuración viene la recompensa de ver el juego funcionando mejor y a usuarios mas felices, así que para mi tiempo bien invertido.
Para variar un poco y no hacer todos post del emulador decidí escribir de nuevo sobre Unity, y sobre algo que veo que genera muchas dudas: cómo se comprimen (o no) las texturas que se importan al proyecto.
Voy a hablar de tres temas:
Qué settings afectan al tamaño final de las texturas y que significa cada una
Cómo empaqueta las texturas Unity al hacer un build
Cómo se usan las texturas al ejecutar el juego
En esta primer parte voy a hablar del primer punto que es el mas complejo, y en el siguiente post sobre los otros dos.
Inspector – Importación
Al importar una imágen hay muchas opciones para configurar, pero yo solamente voy a hablar de las que afecten al tamaño de la textura y de la información abajo de todo en la vista previa:
Las opciones del inspector pueden cambiar para distintos tipos de textura.
Compresión
Cuando alguien trabaja con imágenes es común que trabaje en formatos sin compresión en algún programa de diseño y al terminar exporte una o varias imágenes en algún formato comprimido, como JPG, PNG o GIF. Cada uno de esos formatos tiene sus ventajas y desventajas, pero la mayor ventaja es que casi todo el software que usamos a diario los soporta, por eso tiene mucho sentido usarlos para archivos que transferimos y vemos en una variedad de programas (navegador, galería del teléfono, editor de imágenes, mensajes de Whatsapp, etc).
Pero con juegos el uso de las imágenes es muy diferente. Salvo casos puntuales, son solo para mostrarlas en el juego mediante la GPU. Esto permite preparar las imágenes en otro tipo de formatos, diseñados no para ser transferidos sino para funcionar muy bien en cierto hardware, y en particular en ciertas placas de video.
La mayoría de los engines incluyen funcionalidad para poder trabajar con imágenes en los formatos mas populares e internamente usan formatos mas específicos, y acá es donde entran los settings de compresión al importar una imágen.
Pregunta frecuente:
¿A que formato exporto desde *mi programa de diseño* para después importar a Unity?
Respecto del tamaño final del archivo: no importa. A lo que me refiero con esto es a lo que comenté mas arriba, los engines internamente no usan la imágen tal cual uno la importó al proyecto. Si la imagen es .JPG, .PNG o .BMP, Unity va a usar una versión de la imágen que generó a partir de la original y que guarda en la carpeta «Library» del proyecto junto con todos los demás assets importados.
Es posible obtener el mismo resultado (es decir, el mismo asset procesado) con cualquier formato del archivo original.
El formato de la imagen SI importa en cierto sentido por el uso que se le va a dar: si es para interfaces seguramente convenga .PNG porque soporta transparencias; si es para una textura de un modelo 3D entonces .JPG capaz es una buena opción; o incluso es posible no exportar e importar directamente archivos de Photoshop, y Unity va a importar cada layer por separado.
Lo que quiero dejar en claro con esto es que el formato al que exportan las imágenes para usarlas en Unity depende del uso que se le va a dar y de lo que le quede mas cómodo al equipo de trabajo.
En este link hay una lista de todos los formatos que se pueden importar en Unity.
Proceso de importación
Cuando se agrega una imagen al proyecto Unity la importa, como con cualquier otro asset. Este proceso va a aplicar las distintas settings de importación, incluida obviamente la compresión.
Como primer paso la imágen se descomprime, Unity trabaja con toda la información de cada pixel.
El segundo paso es aplicar los distintos settings como el filter mode o el max size.
El tercer paso es aplicar el formato y la compresión elegida y guardar el archivo en la carpeta Library.
Pregunta frecuente:
¿Me conviene exportar primero un .JPG para web así lo comprime mucho y después lo vuelvo a comprimir con Unity?
No. Como dije, si la imagen está comprimida Unity la descomprime cuando empieza a importarla, el tamaño en bytes de la imagen original deja de tener importancia en este paso, las compresiones no se acumulan. Pero no solo «no se acumulan», como el paso 3 es aplicar otro formato de compresión que NO está pensado para transferencia de archivos, el tamaño de la imágen ya importada puede ser (y en general es) mayor que el del original. Esto es uno de los puntos que genera mayor confusión, porque al desconocer el proceso y los distintos formatos es natural sorprenderse cuando un .PNG de 17.8KB pasa a ocupar 2MB al importarlo y comprimirlo.
Para entender un poco mas cómo se llega a esos números hay que hablar de los distintos formatos de compresión disponibles en Unity, pero antes de terminar esta sección, mi consejo es: Si van a exportar imágenes en formatos comprimidos, NO usen formatos con pérdida para optimizar el tamaño (JPG es un formato con pérdida, PNG no). Y si van a usar un formato con pérdida, traten de no usar la configuración de mayor compresión, porque es la que mas pierde calidad para poder comprimir. Pueden haber motivos para usarlos, pero no los usen solo para tener archivos mas chicos, porque van a estar perdiendo calidad, nada mas.
Formatos de compresión
Creo que esta parte es la mas complicada por varios motivos:
Hay MUCHAS opciones
Cada opción tiene sus características que afectan el tamaño del archivo procesado y la calidad
Cada plataforma (Android, PC, iOS, etc) soporta diferentes formatos
Por si no quedó claro con los puntos de arriba, no hay un formato ideal, sino no habrían tantas opciones. Hay que considerar cada caso, pero además no es seguro que uno formato sea mejor que el resto objectivamente. ¿Prefiero un formato que comprime mas bajando la calidad o uno que comprime menos pero no pierde detalles? ¿Prefiero un formato que comprime menos pero es compatible con mas dispositivos o uno que comprime mas pero solo lo soportan teléfonos de alta gama? Nadie fuera del equipo de desarrollo puede responder esas preguntas.
Override
Lo primero que hay que tener en cuenta es que distintas plataformas tienen distintas limitaciones. No es lo mismo ver un juego en un monitor 4K teniendo disponible todo el procesamiento de una PS4 Pro que en un smartphone FullHD de gama media, así que Unity permite configurar las opciones de compresión para cada plataforma:
La pestaña de «Default» tiene algunas opciones generales que se traducen en formatos de compresión equivalentes en cada plataforma, pero pasando por las distintas pestañas se pueden configurar valores específicos para cada una. Al cambiar el proyecto de plataforma (File -> Build Settings -> Switch Platform) se van a reimportar los archivos con los settings de esa plataforma, se van a sobreescribir los archivos de la carpeta Library y los tamaños de las imágenes ya procesadas van a cambiar.
«RGBA Compressed ETC2» en Android comprime mucho mas que «RGB Compressed DXT1» en Windows, pero el gradiente no se ve tan suave.
Algo a tener en cuenta es que, incluso usando la configuración default y sin overrides, el tamaño en distintas plataformas puede variar, porque el formato final que usa en cada caso puede cambiar. Yo recomiendo no usar la pestaña default y configurar los overrides para tener mas control.
RGBA
En la documentación de Unity hay una tabla con todos los formatos con un montón de información.
Entre todos los datos hay líneas que dicen «R», otras «RGB», otras «RGBA» y otras «Alpha». RGBA son los 4 canales que se usan para definir el color y la opacidad/transparencia de una imágen:
R (red), G (green) y B (blue): son las componentes de cada color (rojo, verde y azu)
A (alpha): es que tan transparente u opaco es el pixel.
No es el único formato para representar colores en una PC, pero no tengo tanto conocimiento como para hablar en detalle sobre colores. Lo que puedo decir es que es importante saber qué canales usa una imagen al elegir el formato de compresión.
Este es uno de los factores que puede incrementar innecesariamente el tamaño de una imagen al importarla. Por ejemplo, si tengo una imagen sin transparencias y elijo un formato con canal A, Unity va a agregar información de alpha para cada pixel (100% opaco) antes de comprimir. En este caso puede que convenga un formato que solo use RGB.
Haciendo click en los botones «R», «G», «B» y «A» se pueden ver los canales por separado. Si el formato no tiene canal A, la opción A no aparece. La imagen original no tiene Alpha, eligiendo RGB me ahorré 4 MB.
Para una imagen que se usa como «height map», que puede pensarse como una escala de grises, un formato que guarde RGBA tiene mucho desperdicio, en general un formato con un solo canal (R o A) es suficiente.
Pero no todo tiene que ver con desperdicio. Por ejemplo, para una imágen con transparencias probablemente un formato que solo guarda RGB no sirva y haya que usar uno con RGBA.
Elegir un formato con la cantidad de canales correctos afecta el tamaño del archivo procesado. No es lineal para todos los formatos, pero en general una imágen con un solo canal de información va a ser 4 veces mas chica que si se usan los 4 canales.
8/16/24/32 bits
Para representar un color en memoria hay que expresarlo como un número. Las consolas o PCs antiguas representaban los colores en diferentes formatos y tenían diferentes limitaciones en la cantidad de colores que podían representar. Por ejemplo el GameBoy original soporta solo 4 tonos de gris para fondos, y 3 tonos de gris mas transparencia binaria para los sprites, y representa cada color usando 2 bits + una paleta de 8 bits (más información).
Con el tiempo el formato se fue estandarizando y se empezó a representar cada color con los 4 componentes RGBA que mencioné antes. Desde hace muchos años ya casi todas las pantallas de casi todos los dispositivos soportan colores en lo que se llama «True color», cada componente se representa con un número de 8 bits y un color termina siendo un número de 32 bits, por lo que se pueden representar 16,777,215 colores, cada uno con 256 «grados» de transparencia.
En la tabla de la documentación hay filas que dicen cosas como «R 8 bit» o «RGB 24 bit». Lo que significa eso es que el formato soporta ciertos canales, y que cada pixel se representa con esa cantidad de bits. «R 8 bit» es un solo canal (rojo) y 8 bits (1 byte) por pixel, por lo que una imagen de 64×64 pixeles en ese formato tendría un tamaño de 64×64 = 4096 bytes = 4KB.
La imagen puede que sea mas chica que la real en sus pantallas y eso puede causar que se vean diferentes, pero son todos cuadrados alternados de blanco a negro, la imagen no perdió calidad al importarla.
En general en la tabla el tamaño por pixel es 8 bits por cada canal, y en esos casos en la segunda columna dice «High-Quality» o «True Color». Pero hay algunos casos como «R 16 bit» o «RGBA 16 bit» que no cumplen eso. Estos formatos usan mayor o menor cantidad de bits para cada color, por eso en el caso de «R 16 bit», que usa 16 bits en lugar de 8, la segunda columna dice «Ultra-High-Quality» y va a usar el doble de espacio en disco, mientras que «RGBA 16 bit» está usando 4 bits por canal en lugar de 8 y por eso es «Low-Quality».
Para saber que formato elegir hay que analizar cada caso, y probablemente haya que decidir entre un formato de mayor calidad y otro de menor tamaño.
Lo importante a tener en cuenta al elegir una representación de menos bits es que al tener menos variedad de colores puede ocurrir lo que se conoce como «Banding» o «Bandeado«, que es cuando varios colores cercanos (en valor y en la imagen) se terminan representando como un mismo valor al bajar la precisión, produciendo bandas de colores con saltos muy marcados. Esto es muy notorio cuando hay gradientes de colores:
«ARGB 16 bit» usa 4 bits por canal en vez de 8. Al no poder representar tantos tonos entre amarillo y verde se generan esas bandas bien definidas.
Este problema es muy común y existe una técnica para simular mayor variedad manteniendo una precisión baja llamada dithering. No se si hay formas de generar el dithering al momento de importar una textura en Unity, creo que en general es un proceso que tiene que hacerse al crear la imagen original.
Sin compresión / Compressed / Crunched
Si bien todos los formatos se configuran en el mismo lugar y yo los vengo llamando «formatos de compresión», no todos comprimen la información. Por ejemplo, «RGBA 32 bit» va a usar 32 bits (4 bytes) por pixel, si importo una imagen el doble de ancha, voy a terminar con un archivo el doble de grande. En estos casos obviamente no se pierde calidad pero no suele ser la mejor opción.
Otros formatos como DXT SI comprimen, no generan pérdida de calidad y además mantienen un crecimiento lineal según el tamaño de la imágen. En general conviene algún formato de este tipo.
Y por último hay algunos formatos «crunched» que además de comprimir son con pérdida y usan una cantidad de bits variable para cada pixel. Estos formatos son los que mas se acercan a un formato de transferencia como .JPG, ajustan la precisión de forma variable de acuerdo a los colores de cada pixel de la imagen.
Dice «RGB Compressed DXT1» como en el ejemplo anterior, pero le puse la compression DXT1 «Crunched».
Pregunta frecuente:
¿Entonces elijo siempre el formato de mayor compresión?
Si llegaste hasta este punto te imaginarás la respuesta, pero… no es tan simple. Por un lado como dije a veces hay que elegir entre un formato con mayor precisión y otro con menor tamaño, o capaz para una imagen es preferible un formato con R 8 bit (que no comprime) en lugar de uno como DXT (que comprime, pero trabaja con mas canales). Pero además cada plataforma y cada GPU puede soportar o no un formato, tema del próximo post.
MipMaps
Cuando se dibuja un modelo en 3D en algún punto se hace lo que se llama «tomar una muestra» de la textura para saber que pixel de la textura mostrar en un pixel de la pantalla. Si el objeto ocupa una parte importante de la pantalla se pueden ver bien los detalles de una textura, sobre todo los cambios rápidos y frecuentes de color. Pero cuando el mismo objeto se aleja o se achica y ocupa una fracción de la pantalla se pueden producir algunos errores visuales, sobre todo si dos pixeles consecutivos en la pantalla se «saltean» una parte con mucho detalle de la textura.
Los dos planos usan la misma textura, el de la derecha está mucho mas lejos.
Para mitigar un poco este problema existen los «mipmaps», que son versiones de la textura reducida en tamaño varias veces que se guardan junto con la textura original, y la GPU automáticamente usa las versiones mas chicas para objetos que se achican mucho. Al generar estas versiones mas chicas se pueden usar algoritmos específicos para redimensionar, y si bien se pierde definición al reducir la imagen a 1/4 de su tamaño varias veces, se mantienen algunos detalles «distribuyéndolos» en los pixeles que si quedaron.
Así se ve la misma escena después de generar mipmaps:
No se arregla completamente, pero se mezclan un poco mas los negros y blancos y no se ve TAN mal. Recomiendo buscar mas ejemplos en google sobre el efecto de los mipmaps.
En Unity se pueden ver los mipmaps generados moviendo este slider:
Como los mipmaps se guardan junto con la misma textura, el tamaño del archivo es un poco mas grande porque la textura se hizo mas ancha. La misma imagen sin mipamps:
Como los mipmaps se usan cuando un objeto cambia de tamaño en la pantalla, para tipos de juego u objetos que no cambian tanto la distancia es buena idea deshabilitar los mipmaps. Por ejemplo, en un FPS con escenarios grandes probablemente sean necesarios para algunas texturas, pero en un juego isométrico pueden no hacer falta. Sea como sea el juego, en general todo lo que se use para sprites y sobre todo lo que se use para UI no se beneficia de tener mipmaps, así que yo por defecto en esos casos lo deshabilito y después si hace falta lo vuelvo a habilitar.
Potencia de 2 y (NPOT)
Algo que se repite mucho cuando se habla de los métodos de compresión es que hay que usar imágenes con dimensiones potencia de 2, es decir que el ancho y el alto valgan: 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, etc. Cuando una imagen no cumple con esto se puede leer en varios lados «(NPOT)», que significa «Non-Power of two», o «No potencia de 2».
Eso es cierto pero no en todos los casos, así que es importarte mirar el inspector después de aplicar una compresión, va a aparecer una advertencia con el motivo si no pudo aplicarla.
Algunos formatos funcionan para dimensiones múltiplos de 4, así que trabajar siempre con potencias de 2 puede ser un desperdicio y además una carga extra para los/as artistas, que tienen que adaptar sus diseños a una restricción que el formato de compresión no tiene.
Sea cual sea el formato elegido, no cumplir los requisitos hace que se use un formato sin compresión y obviamente el tamaño termina siendo mayor del esperado, así que es importante leer las advertencias:
En este ejemplo la imagen mide 359×154 (no es ni múltiplo de 4 ni potencia de 2). Aunque el formato elegido es «RGBA Compressed», el formato aplicado es «RGBA8».
Leí muchas veces a desarrolladores decir que si la textura no cumple con las restricciones, Unity automáticamente «agrega espacio vacío para hacerla potencia de 2» y que por eso es mas grande, y que después el shader tiene en cuenta esto e ignora ese espacio. Esto NO es así, Unity no agrega espacio en la imagen, el mayor tamaño es por usar un algoritmo sin compresión. Si Unity realmente agregara espacio vacío entonces la compresión podría aplicarse y no se verían esos tamaños tan grandes.
Algo parecido a esa afirmación es algo que Unity SI puede hacer en algunos modos de importación, y es estirar o ahicar la imagen hasta hacerla potencia de 2:
Esta opción puede estar configurada por defecto en una diferente a «None», así que es importante revisarla. Si se usa alguna de las otras opciones, Unity estira o achia la imagen en cada dirección hasta una potencia de 2. Esto deforma la imagen, que puede no ser un problema para texturas en modelos 3D, pero es importante saberlo:
En este caso la imagen se comprime bien sin ser potencia de 2, la versión de la derecha es mas chica en tamaño porque es mas chica en medida, no porque se haya comprimido mejor, pero el círculo ya no es un círculo.
Pero igual cumplir con esas restricciones, incluso la de múltiplos de 4, puede ser medio molesto para los/as artistas. Agrega una carga de trabajo extra de verificar los tamaños de los archivos exportados, y a veces los programas de diseño no permiten tanto control sobre el resultado final. Esto es un problema sobre todo para artistas que hacen UI, porque si una imagen necesita un tamaño específico para funcionar dentro del diseño completo, para hacerla multiplo de 4 o potencia de 2 hay que agregar espacio vacío en la imagen, y después hay que usar el editor de sprites (que ahora además es un package que hay que instalar) al importarla para ajustar el área de la imagen a lo que es realmente el sprite.
Quiero un sprite de 197×73, pero para hacerlo múltiplo de 4 tuve que agregar 3 pixeles mas en cada dirección y recortarlo con el Sprite Editor. Si cambio el tamaño del sprite tengo que volver a recortarlo.
Para estos casos en lugar de ajustar cada imagen para que cumpla los requisitos por separado una mejor opción es usar sprite sheets. Un spritesheet es un archivo, en general de dimensiones potencia de 2, que contiene varias imágenes individuales distribuidas para aprovechar el espacio. Si bien es posible generar estos spritesheets antes de importarlas y es muy común hacerlo así para cuadros de animación de un personaje, Unity permite generarlas automáticamente a partir de archivos individuales durante el proceso de importación.
No voy a explicar como configurar los spritesheets en este post, pero quiero dejar en claro que NO es necesario que imágenes que se usen para sprites o UI sean potencia de 2 ni múltiplo de 4, usen sprite sheets siempre que puedan y van a trabajar mucho mas rápido.
Es posible generar un sprite sheet con una sola imagen. Esto puede ser útil para imágenes grandes como un fondo si quieren usar un formato que requiere potencias de 2. Es común usar fondos que miden 1920×1800, ninguna de las medidas es potencia de 2, pero se puede configurar un spritesheet de 2048×2048 con esa imagen. Esto es equivalente a que el/la artista le agregue espacio vacío a la imagen para cumplir la restricción, pero tiene dos ventajas:
Lo hace Unity automáticamente, solo hay que configurar el spritesheet.
Al estar en un spritesheet el tamaño real del sprite ya está definido, no hay que hacer ajustes ni seleccionar areas a mano.
Max Size
El último setting del que quiero hablar es el de tamaño máximo. Esta opción redimensiona una imagen al momento de importarla para que no supere cierto valor en ninguna dirección. Es parecido al setting de «Advanced -> Non-Power of 2» que mostré antes, con la diferencia de que la imagen se redimensiona proporcionalmente, así que no hay que preocuparse porque se deforme, pero puede pasar que al redimensionarla deje de cumplir las restricciones del formato de compresión elegido.
El círculo no se deforma, pero la imagen original sin MaxSize se pudo comprimir mejor que la limitada.
Conclusión
Si llegaron hasta acá ya se habrán dado cuenta que este tema es mucho mas complejo de lo que puede parecer al principio. Algo bueno es que no es necesario preocuparse por todo esto desde el dia 1, en general dejando todo con la configuración por defecto el juego va a funcionar. Pero si le dedica tiempo a entender el tema se puede reducir considerablemente el tamaño del juego sin perder calidad. De la misma forma, cambios chicos pueden afectar negativamente la compresión e incluso empeorar la calidad de las texturas si no se tiene cuidado, por eso es bueno tarde o temprano entender estos temas. Creo que ningún juego debería considerarse listo para publicar en una tienda si no se hizo una revisión de estos settings y una validación de que funcionan como se espera en el dispositivo final. Sobre este último punto voy a hablar en la parte 2, porque se puede configurar todo a la perfección y después encontrar algunas sorpresas.
Para probar el emulador obviamente hay que probar juegos, todos los que sea posible. Al implementar los componentes principales los juegos de a poco empiezan a funcionar, pero cada juego hace las cosas a su manera y algunos dependen de comportamientos muy específicos del hardware.
Cada tanto pruebo algunos juegos de GB y GBC que elegí medio al azar y anoto cuales tienen problemas, y cuando los hago funcionar agrego mas juegos a las pruebas. Los problemas pueden ser muy variados, pero un mismo problema puede afectar a varios juegos. Es importante probar los que ya funcionaban cada tanto para segurarse de que un fix no rompa otras cosas.
La idea de este post es hablar sobre algunos errores que arreglé últimamente. Algunos de estos problemas tienen la particularidad de que dependen de cosas que no están del todo documentadas, por esto la forma de arreglarlos puede que no sea la correcta.
Cómo dije antes, un error puede afectar a mas de un juego, así que aunque ponga capturas de uno en particular el fix puede servir para otros que no probé.
Turok – Rage Wars (HDMA1-5)
Este juego tenía problemas gráficos importantes y mas allá de que el juego avanza (se puede llegar hasta el momento de gameplay) era claramente injugable. Se veía así:
Pero tendría que verse así:
En todas las pantallas se dibuja el fondo hasta cierto punto, después de ahí se ve todo blanco. La flecha que se ve mas abajo es un sprite, por eso se ve bien.
Lo primero que hice fue mirar las ventanas de debug de Tiles y la del TileMap 0 que es el fondo en esas pantallas:
La información en los tiles está, pero el tilemap no está haciendo referencia a los tiles correctos, así que usé los logs del emulador para ver si encontraba algo y vi esto:
HDMA1 = d0
HDMA2 = 00
HDMA3 = 18
HDMA4 = 00
mode: 0
HDMA5 = 00
GDMA started <- Inicia una transferencia de 0x10 bytes
Transfering
Count: 10
Source: d000
Dest: 9800 <- [0x9800, 0x9C00] define los tiles que usa el TileMap 0
VRAM0[9800] = 01
...
VRAM0[980f] = 0f
Transfer finished
HDMA5 = 00
GDMA started <- Inicia otra transferencia de 0x10 bytes
Transfering
Count: 10
Source: d000 <- Mismo origen
Dest: 9800 <- Mismo destino
VRAM0[9800] = 01
...
VRAM0[980f] = 0f
Transfer finished
Marqué en negrita las direcciones de origen y destino que usa DMA para copiar la información. En lugar de copiar 0x10 bytes de D000 a 9800 y después copiar 0x10 de D010 a 9810 (y así hasta completar la pantalla), está copiando siempre los bytes de las primeras 16 posiciones.
También se puede ver que HDMA1-4 (definen el origen y el destino) se setean una sola vez y después se inicia varias veces la transferencia escribiendo 00 en HDMA5. Esto es raro, la mayoría de los juegos que probé hasta ahora siempre escriben HDMA1-4 antes de cada escritura a HDMA5.
El problema en este caso es un comportamiento que no leí en ningún lado: HDMA1-4 se modifican mientras HDMA copia los bytes! De esta forma, al terminar la primer transferencia de 0x10 bytes, HDMA2 y HDMA4 van a incrementarse en 0x10, y cada 0x100 bytes también van a incrementarse HDMA1 y HDMA3. Por esto es posible copiar valores para toda la pantalla escribiendo varias veces seguidas en HDMA5 pero una sola vez en HDMA1-4.
Para arreglar esto sólo tuve que asegurarme de actualizar HDMA1-4 cada vez que termina una copia con la cantidad de bytes copiados.
3D Ultra Pinball, Little Mermaid (HDMA y HBlank)
Otros dos juegos que comparten código y un mismo bug. En este caso de vuelta era un problema gráfico que hacía que fueran injugables:
Y así es como tienen que verse:
Viendo los TileMaps se ve que la información no está bien, que puede ser por dos motivos: los TileMaps están mal creados o los Tiles están desordenados. Se me ocurrió revisar lo primero, así que busqué en los logs cuando se escribe la memoria desde 0x9800 y vi esto:
line: 29
mode: 2
mode: 3
mode: 0 : <- GPU en modo HBlank
HDMA4 = 00
HDMA3 = 08
HDMA2 = 00
HDMA1 = c8
HDMA started
HDMA5 = 80 ; 00 <- Configura HDMA para transferir 0x10 bytes en modo HBlank
line: 30 <- Nueva línea
mode: 2
mode: 3
mode: 0 : <- GPU en modo HBlank
Transfering
Count: 10
Source: c800
Dest: 8800
VRAM0[8800] = 00
....
VRAM0[880f] = 00
Transfer finished
HDMA4 = 10
HDMA3 = 08
HDMA2 = 10
HDMA1 = c8
HDMA started
HDMA5 = 80 ; 00 <- Configura de nuevo HDMA para transferir 0x10 bytes en modo HBlank
line: 31
mode: 2
mode: 3
mode: 0
Transfering
Count: 10
Source: c810
Dest: 8810
VRAM0[8810] = 00
....
VRAM0[881f] = 00
Transfer finished
HDMA tiene un modo que copia de a 16 bytes en cada HBlank a partir del momento en que se escribe HDMA5 y estos juegos lo usan, pero lo usan raro.
En primer lugar el modo HDMA está pensado para escribir una sola vez en HDMA5, y el hardware automáticamente va a copiar de a 0x10 bytes en cada HBlank, pero acá están usándolo para copiar de a 0x10 bytes y hacen una escritura nueva en cada línea. Normalmente el valor de HDMA5 debería haber sido 0xFF, y eso automáticamente hubiese copiado 0x800 bytes.
Pero ese uso raro no es el problema, el problema es que inician la transferencia cuando la GPU está en modo HBlank, algo que según la documentación no se recomienda.
Al leer eso asumí que ningún juego hacía eso, así que programé que después de escribir HDMA, cada vez que se inicie el modo HBlank se copien valores. Estos juegos no estaban siguiendo el consejo, así que se me ocurrió que capaz si se escribe HDMA5 durante un HBlank se hace una primera transferencia en ese momento. Implementé ese cambio y los 2 juegos se arreglaron!
El fix es muy simple, sólo agregué lo que está en negrita:
void GPU::Write(u8 value, u16 address) {
...
case 0xFF55:
dma.Write(value, address);
if (mode == GPUMode::HBlank)
dma.StepHDMA();
break;
...
}
Lufia (MBCs)
Este juego directamente no iniciaba. Me gusta tratar de arreglar juegos con este problema porque es mucho mas fácil de reproducir y comparar con el debugger de BGB.
La verdad para este problema tuve que comparar paso a paso con BGB hasta que me di cuenta que estaba pasando, y es otro de esos comportamientos no documentados. Antes de explicar la solución voy a explicar el problema.
Ya conté en un post como los juegos pueden tener diferentes tamaños gracias a los distintos tipos de MBC que existen. La función del MBC es «mapear» una parte de todo el contenido del juego en una región de memoria, y a estas partes se las llama «bancos». En el header del juego uno de los bytes determina cuantos bancos tiene el juego, porque para un mismo tipo de MBC algunos juegos pueden ser mucho mas chicos que otros.
Cuando se quiere seleccionar un banco en particular se escribe un número en cierta dirección de memoria, y ese número lo usa el MBC para elegir el banco. Este número es un byte, así que en teoría se puede escribir cualquier número de 0 a 255… ¿Pero qué pasa si un juego con 64 bancos escribe, por ejemplo, 250? No está documentado, pero además uno podría pensar… ¿Si un juego tiene 64 bancos, por qué escribiría un número de banco mayor? Los motivos la verdad no los conozco, pero resulta que hay juegos que lo hacen! Lufia es solo un caso, muchos otros hacen lo mismo.
La solución en este caso la tuve que deducir, y lo que entiendo es que si el RomBank seteado es mayor al disponible, se hace un «mod». Es decir, si se escribe el valor «250» pero el juego sólo tiene 64 bancos, se selecciona el banco 58 (250 % 64).
Esto, por lo que pude ver con otros juegos que tenían problemas, es cierto para TODOS los MBCs.
Darkwing (LCDC)
En algunas transiciones entre menues/juego, la imagen se veía rara.
Antes de la transición
Durante la transición
Me llamó la atención y probé en otros emuladores, y vi que en esos momentos lo que debería verse es la pantalla toda blanca. Ahí me acordé que en la documentación de la GPU aclaran que cuando la pantalla se apaga se ve todo blanco (no negro como en un monitor moderno), así que me imaginé que era eso.
El problema en mi emulador no es que eso funcionara mal, directamente es algo que había leído en la documentación pero que nunca implementé, así que lo implementé teniendo en cuenta esto:
Cuando el bit 7 LCDC pasa de 1 a 0 significa que la pantalla se apagó y tiene que verse toda blanca
En GB y en GBC en modo Non-CGB, cuando el bit 0 de LCDC es 0 el fondo no se tiene que dibujar, así que esa línea tiene que quedar blanca (después la ventana o los sprites pueden dibujarse encima del blanco)
Para implementar esto se me ocurrió que podía usar la estructura de PixelInfo que definí cuando implemente soporte para GBC. Esa estructura tiene información (paleta, si es bg o sprite, etc) que después se usa para determinar el color de cada pixel. Cuando la definí me sobró un bit, así que adapté un poco las cosas para que ese bit en 1 signifique que es blanco:
typedef union {
u8 v;
struct {
u8 colorIndex : 2; // Bits 0 - 1 colorIndex [0,3]
u8 paletteIndex : 3; // Bits 2 - 4 paletteIndex [0,7] for CGB, [0,1] for sprites in GB, 0 for BG in GB
bool isBG : 1; // Bit 5 bg/win or sprite, 1 for BG, 0 for sprites
bool bgPriority : 1; // Bit 6 BG priority (CGB only, used when rendering sprites)
bool blank : 1; // Bit 7 Set to 1 when LCD is off
};
} PixelInfo;
Así, al apagar la GPU puedo poner toda la pantalla en blanco de esta forma:
void GPU::Write(u8 value, u16 address) {
...
case 0xFF40: {
bool wasOn = LCDC.displayOn;
LCDC.v = value;
if (LCDC.displayOn != wasOn) {
if (LCDC.displayOn) {
SetCurrentLine(0);
mode = GPUMode::OAMAccess;
modeCycles = 0;
} else
memset(screen, 0x80, LCDWidth * LCDHeight); // 0x80 = PixelInfo con el bit "blank" en 1
}
break;
}
...
}
Y cuando voy a dibujar una línea, si el fondo está apagado, puedo poner los 160 pixeles en blanco de esta forma:
void GPU::DrawLine(u8 line) {
bool drawBG = isCGB || LCDC.bgOn;
...
if (drawBG)
DrawBackground(line, drawWIN ? WX : LCDWidth + 8);
else
memset(screen + line*LCDWidth, 0x80, LCDWidth); // 0x80 = PixelInfo con el bit "blank" en 1
...
}
Por último tengo que tener en cuenta el bit en GetABGR
En este caso estuve un montón de tiempo debuggeando y encontré la solución. Pensé que era algo no documentado, pero después busqué información y lo encontré explícito en la documentación, así que no voy a contar todo lo que hice para llegar a la solución porque no tiene sentido. El problema está relacionado con como se dibujan los sprites de 8×16. Para este tipo de sprites se hace referencia a un solo tile, pero se dibujan 2 consecutivos de memoria uno abajo del otro. Si un sprite usa el tile 0, en la pantalla se dibujan el 0 y abajo el 1.
Lo que me faltaba emular fue que para sprites de 8×16 hay que ignorar el bit 0 del «TileIndex». Es decir, si el TileIndex es 1 no hay que dibujar los tiles 1 y 2, hay que dibujar el 0 y el 1.
X-Men: Mutant Academy, Tony Hawks (Serial Transfer)
Estos dos juegos no comparten el código, pero tenían un problema similar: se quedaban trabados.
En estos casos lo primero que hago es mirar los logs a ver que está pasando, en general el código está en un loop esperando que pase algo (una interrupción o que un valor cambie) y eso nunca pasa. En los logs del X-Men noté que estaba bloqueado en este loop:
LDH A,[0xFF02] ; [0xFF02] = ff
ADD A,A
JR C,-5 ; F = 30
Para entender el problema hay que entender ese código, a la izquierda están las instrucciones que se ejecutaron y a la derecha después de «;» un comentario que logueo según la instrucción para saber que valores tienen ciertos registros o direcciones de memoria.
La primer instrucción está cargando en el registro A el valor en la dirección de memoria 0xFF02, que en ese momento vale 0xFF, así que A pasa a valer 0xFF.
La segunda instrucción suma A con A, es decir 0xFF + 0xFF. El resultado es 0x1FE, por lo tanto A pasa a valer 0xFE y el bit de Carry en el registro de F (flags) se poné en 1.
La última instrucción es un salto relativo condicional, va a volver 5 «bytes» para atrás si el bit de Carry de F está en 1 y va a seguir si está en 0. Cómo en el paso 2 se puso en 1 ese bit, el flujo vuelve a la primer instrucción.
El valor final de A en el segundo paso realmente no importa, esa instrucción se usa a veces para saber si el bit 7 de un registro está prendido o no, que es lo que interesa en este caso. Si A valiera 0x80, ADD A,A daría 0x100, es decir, también se pone en 1 el bit de Carry. Puede ser confuso siendo que existe una instrucción específica para chequear si cierto bit de un registro vale 1 o 0 (BIT 7,A), pero cuando se programaba en hardware tan limitado era común encontrar este tipo de optimizaciones. Es una optimización porque la instrucción ADD A,A usa un byte en el programa y tarda un ciclo de procesador, mientras que BIT 7,A usa 2 bytes y tarda 2 ciclos.
El registro 0xFF02 es «Serial Transfer Control» y el bit 7 tiene dos funciones: iniciar la transferencia a otro GameBoy conectado por Link Cable y reportar el estado actual de la transferencia (1 = en progreso, 0 = terminada). En mi emulador la transferencia nunca termina, el bit 7 siempre está en 1. Para arreglar este problema hay que tener en cuenta que haya o no otro GameBoy conectado, la transferencia «se hace», tiene que terminar, es decir que después de cierto tiempo el bit 7 de 0xFF02 tiene que pasar a 0. Yo nunca implementé esto porque supuse que si no elegía opciones específicas de link no se iba a usar, pero no… Así que emulé la transferencia por link cable y actualizo el componente junto con el resto después de cada instrucción, y esos juegos ya no se traban.
void SerialDataTransfer::Step(u8 cycles, bool isDoubleSpeedEnabled) {
if (bitsTransfered == 8)
return;
elapsedCycles += (isDoubleSpeedEnabled ? cycles * 2 : cycles);
while (elapsedCycles >= maxCycles) {
bitsTransfered++;
elapsedCycles -= maxCycles;
if (bitsTransfered == 8) {
SC &= ~0x80; // pongo el bit 7 en 0 después de "transferir" 8 bits
return;
}
}
}
Algo a tener en cuenta, aunque no se esté transfiriendo nada realmente, es que el modo Double Speed de la CPU afecta el tiempo de transferencia por serial, por eso tengo que pasarle el dato.
Este VideoPlayer aparentemente lo usa y funciona en hardware real, así que es posible que algún juego oficial también lo necesite.
Ya hablé varias veces sobre los 4 modos por los que pasa la GPU y sobre las interrupciones así que no voy a explicarlos en detalle de nuevo, pero aparentemente esto es lo que pasa:
Se dibuja la línea 143 pasando por los modos normales de OAM -> VRAM -> HBlank
Empieza la línea 144 y se pasa a modo VBlank
PERO si el bit de OAM de LCDStat está seteado, se genera una interrupción de LCDStat como si el modo OAM hubiese iniciado (que no corresponde a partir de la línea 144).
Esto hace que durante un frame hayan 145 interrupciones de LCDStats por OAM, pero sólo 144 por HBlank. VideoPlayer espera 145 LCDStats por frame, así que depende de este comportamiento. La discusión en el foro se pone medio confusa y el comportamiento es tan oscuro que no se llega a una conclusión unánime, pero aparentemente esta solución funciona y hasta tiene cierta lógica. Mi implementación pasa el test rom que compartieron en ese foro.
Sagaia, The Jetsons, Final Fantasy Legend (LY 153)
En el post anterior conté que hay un comportamiento raro durante la última línea de VBlank pero revisando mas juegos noté que la explicación anterior no era suficiente. O sea, funciona para el modo HiColor de los juegos que probé mientras escribía el post anterior, pero para otros no. En Sagaia no se dibujaba el fondo, en The Jetsons el fondo no scrolleaba, Final Fantasy Legend no iniciaba.
En línea generales lo que conté en el post anterior sigue pasando, durante VBlank la última línea, equivalente a LY 153, se comporta como una falsa línea 0. Esta línea puede generar una interrupción si LYC vale 0, y cuando termina VBlank la línea siguiente es de nuevo LY 0. De esta forma antes de la línea 0 de la pantalla el juego tiene 456 ciclos para preparar cosas.
Al arreglar estos otros juegos tuve que cambiar dos cosas:
Si LYC vale 153, TAMBIÉN se tiene que generar la interrupción de LCDStat, yo estaba ignorarndo la línea 153 totalmente. Es raro, parecería que la línea 153 dura solo algunos ciclos y después pasa a valer 0 (y hace posible lo que expliqué de HiColor). Esto arregla Sagaia y The Jetsons.
Cuando la línea falsa 0 pasa a la línea real 0, NO se genera una interrupción de LCDStat. Esto arregla Final Fantasy Legends.
Adapté el código para esto pero no lo quiero compartir porque al hacer esto al menos 1 juego con HiColor dejó de funcionar, el Alone In The Dark. Cuando tenga alguna solución que funcione para todos seguramente actualizo este post con el resultado final.
Flash, Seaside Volley, Samurai Shodown y mas
Estos juegos y varios mas tenían problemas para dibujar los sprites en ciertos casos:
Samurai Shodown
Seaside Volley
The Flash
En esas capturas:
Samurai Shodown: No se ve el personaje de la derecha
Seaside Volley: La pelota está incompleta y no se ve el cursor
The Flash: Flash desaparece cuando sube escaleras
El problema lo arreglé para Seaside Volley y cuando probé los otros juegos vi que se habían arreglado también. Lo primero que hice fue mirar la ventana de Sprites pero ahí también faltaban. Después revisé cuando el estado de la memoria OAM (que define los sprites), pero todo parecía estar correcto. Cómo estaba medio perdido implementé mas información de debug en la ventana de Sprites: Al clickear en la pantalla me fijo en la memoria OAM si hay algún sprite en esa posición e imprimo los atributos. Al hacer esto confirmé que los sprites estaban bien definidos, pero no se dibujaban, así que el problema tenía que estar en como los dibujaba.
Después de debugear el código encontré varios problemas, pero se pueden resumir todos en un mismo problema mas general: si un bit de los atributos dice «Sólo en CGB» o «Solo en GB y Non-CGB», hay que respetarlo!
Hay algunos bits en los atributos de los sprites que en GB o en Non-CGB no se usan, y otros que en CGB no se usan. Cómo en muchos otros casos, asumí que si el bit no se usa no tenía que importarme, pero en realidad hay que ignorarlos explícitamente. Es importante al emular GB y GBC con el mismo código, porque los juegos funcionan asumiendo que el valor de ciertos bits no importa, por lo que pueden ponerlos en 0 o en 1 y eso no debería afectar el funcionamiento.
Voy a dar un ejemplo del emulador para que se entienda mejor el problema, considerando el bit 3 de los atributos de sprites que indica en que banco de VRAM están los datos del tile. Un juego de GB puede tener un 1 o un 0 en el bit 3 de sus atributos porque realmente no debería hacer diferencia, pero si uso el valor puedo terminar buscando el tile en el banco 1, cuando en GB ese banco no existe.
Entonces, cuando se van a leer los bytes de un tile en un juego de GB hay que usar el banco 0, independientemente del atributo. También hay que tener en cuenta que los juegos de GB funcionando en Non-CGB pueden configurar cualquier valor en los bits [0,2] que determinan la paleta de GBC, así que en estos casos hay que ignorar el valor, porque para este modo las paletas ya están definidas de cierta forma.
No voy a copiar el código porque es medio confuso (igual que la explicación, perdón!), pero se puede ver en el respositorio donde tengo todo el código disponible: https://github.com/DiegoSLTS/gb
Black Bass, Hammerin’ Harry, Koro Dice y otros (Timer)
Hace mucho conté como funciona el timer, y eso está basado en la documentación, pero en ese link hay otro link a algo que nunca implementé: el «Obscure behaviour» del Timer.
Ese link explica unos comportamientos «extraños» del timer que ocurren por como está construido físicamente. Hay gráficos de lo que sería el circuito, y la verdad es difícil de explicar y difícil de entender, sobre todo sin un buen conocimiento de electrónica. Después de leerlo varias veces y hacer pruebas logré entender e implementar todos los puntos del título «Relation between Timer and Divider register», pero me faltó implementar lo de «Timer Overflow Behaviour».
Lo que creo que es importante de esto es que tuve que reimplementar el Timer, realmente se hace mas simple tratar de emular el circuito que simular y el comportamento raro termina cumpliéndose automáticamente. Con esto me refiero a que, por ejemplo, en lugar de tener DIV como una variable de 8 bits y distintos contadores, tengo una sola variable de 16 bits como usa el hardware, incremento siempre esa variable y si quiero leer «DIV» simplemente me quedo con los 8 bits mas altos.
Emulando estos comportamientos varios juegos empezaron a funcionar, porque este comportamiento termina generando mas interrupciones que la documentación original.
Block Kuzushi, Fighting Simulator, In Your Face (SerialTransfer, otra vez)
Estos juegos también se trababan y estaba relacionado con otros problemas de Serial Transfer, que en realidad es todo consecuencia de que mi implementación de este componente nunca la hice completa, lo vengo emparchando con lo mínimo porque realmente es algo que nunca planeé implementar.
A la implementación que mostré antes le faltan 2 cosas:
Cuando no hay nada conectado y se inicia una transferencia, el byte «recibido» es 0xFF.
Al terminar una transferencia se genera una interrupción. Lo importante de este punto es que incluso si no hay nada conectado y se recibe 0xFF, la interrupción no se va a generar, pero el bit 7 de SC tiene que resetearse.
Estos juegos tenían problemas diferentes, el Harvest Moon por ejemplo se trababa, pero otros mostraban cosas raras. El problema lo resolví comparando el juego corriendo en BGB y encontré que estaba relacionado con un bug en mi implementación de MBC1.
El MBC 1 tiene una región en la que se puede escribir un byte y según como esté configurado, ese byte define parte del número de banco de la memoria ROM o el banco de la memoria RAM. El banco completo de la memoria ROM está definido por la combinación de ese byte y otro que se escribe en otra región.
Por la forma en que implementé los MBC, no uso el número de banco cada vez que se va a leer un byte del juego, sino que precalculo un offset del array con el contenido total del juego, así me ahorro algunas multiplicaciones. El problema era que por mal interpretar el «romRamSwitch», estaba pasando que el juego escribía valores para los bits mas bajos del banco de la memoria ROM con el switch configurado para que los bits mas altos se usen para el banco de la RAM, y yo estaba calculando el offset solo si el switch estaba configurado para ROM. Es decir, estaba ignorando los cambios en el banco de la ROM, ya que el offset seguía calculado para el valor anterior.
Y arreglado es precalcular le offset siempre que se cambie el banco, y quedó así:
void MBC1::Write(u8 value, u16 address) {
if (address = 0x2000 && address < 0x4000) {
u8 lowBits = value & 0x1F;
if (lowBits == 0)
lowBits = 1;
romBank &= 0x60;
romBank |= lowBits;
romBank %= romBanksCount;
romBankOffset = 16 * 1024 * romBank; // Esta es la solución
} else if (address >= 0x4000 && address < 0x6000) {
if (romRamSwitch == 0) {
u8 highBits = (value & 0x03) << 5;
romBank &= 0x1F;
romBank |= highBits;
romBank %= romBanksCount;
romBankOffset = 16 * 1024 * romBank; // Esta es la solución
} else if (ramEnabled) {
ramBank = value & 0x03;
if (ramBank > ramBanksCount)
ramBank = ramBanksCount;
ramBankOffset = 8 * 1024 * ramBank;
}
} else if (address >= 0x6000 && address = 0xA000 && address < 0xC000 && ramEnabled)
ram[ramBankOffset + address - 0xA000] = value;
}
Conclusión
Cuando empiezan a aparecer estos errores espécificos de algunos juegos puede ser bastante difícil encontrar la causa, pero después el fix es relativamente simple. Se complica todavía mas si la solución es por algo que no está del todo documentado, así que es importante tener muchas herramientas para debugear y buscar en foros o incluso código de otros emuladores. También cada vez que se me ocurre algo que me puede ayudar lo implemento, y ya tengo varias cosas que me permiten debuggear algunas cosas sin recurrir a otros emuladores. Por ejemplo, hasta ahora el TileViewer solamente mostraba los tiles, pero agregué que al clickear sobre uno me imprima en consola información del tile (dirección, atributos, etc).
Por el momento voy a dejar el post acá, hay mas juegos que no funcionan pero todavía no pude arreglarlos así que es probable que haga otro post con mas bugs arreglados en el futuro.
Como conté en el post anterior, los juegos de GBC pueden configurar y usar hasta 8 paletas de 4 colores para los fondos y hasta 8 paletas de 3 colores para los sprites. Cada tile de fondos y sprites puede usar una paleta diferente, así que en teoría un juego puede mostrar 56 colores diferentes en un frame, 32 en los fondos y 24 en los sprites. Esto es lo mas común y lo que hacen prácticamente todos los juegos el 99% del tiempo, pero existe una forma de mostrar más de 2000 colores en un solo frame usando lo que llaman «HiColor mode». No es realmente un modo, no es algo que se puede prender o apagar o cambiar, pero se usa ese nombre.
Tomb Raider
Para explicar mejor como funciona esto voy a repasar y detallar un poco mas ciertas cosas de la GPU.
Modos
Mientras la pantalla está prendida la GPU pasa por 4 modos:
OAM: Cuando empieza a procesar una línea no dibuja pixeles inmediatamente, durante un tiempo está chequeando que sprites hay que tener en cuenta al dibujar la línea. La duración de este modo es constante (80 ciclos de GPU, o 20 de CPU) porque revisa siempre los 40 sprites que se pueden definir. Durante este tiempo no se puede leer ni escribir la memoria de los sprites.
VRAM: Cuando se terminan de chequear todos los sprites la GPU empieza a dibujar los 160 pixeles de la línea. Este modo dura al menos 168 ciclos de GPU, pero no es constante. 168 es el mínimo y ocurre si se dibujan solo tiles del fondo; si la ventana está activada o hay sprites en la línea tarda mas tiempo, hasta un máximo de 291 (ventana y 10 sprites, que es el límite por línea). Durante este tiempo no se puede leer ni escribir la información de la GPU, es decir, ni la VRAM, ni las paletas ni los sprites.
HBlank: Cuando se termina de dibujar una línea hay un tiempo en el que la GPU no está haciendo nada y toda la memoria está disponible de nuevo. Este modo tiene una duración variable que depende de la duración del modo anterior, entre 85 y 208 ciclos (85 si VRAM duró 291 ciclos). Este tiempo es variable porque es el tiempo restante para llegar a 456 ciclos por línea, necesario para mantener la taza de refresco constante de 59.7 FPS. Durante este modo la línea de la pantalla ya se dibujó por completo, pero si se lee el registro «LY» de la GPU todavía no cambió. Normalmente después de este modo vuelve a empezar el modo OAM porque empieza la línea siguiente, salvo cuando se terminaron de dibujar las 144 líneas de la pantalla.
VBlank: Después de terminar toda las líneas (es decir, después del HBlank de la última línea) la GPU entra en otro modo en el que tampoco hace nada y toda la memoria está disponible, similar a HBlank. La principal diferencia es la duración de este modo, equivalente a 10 líneas completas, o 4560 ciclos. Mientras pasa este tiempo la GPU reporta números de línea consistentes con el tiempo que pasó, desde la línea 144 hasta la 153, aunque no sean líneas en la pantalla. Normalmente los juegos usan este tiempo para hacer cambios grandes en la memoria de video (ej: cambiar todo el fondo), o para actualizar los sprites.
La GPU está constantemente pasando entre los modos OAM, VRAM, HBlank durante 144 líneas, después VBlank durante 10 «líneas», y de nuevo OAM, VRAM, HBlank, etc.
Interrupciones
Las interrupciones son señales que distintos componentes pueden generar, y que pueden cambiar el flujo del programa que de otra manera seguiría secuencialmente el código de la ROM. Hay varias interrupciones de distintos componentes, pero en particular para la GPU hay 2: VBlank y LCDStat.
La interrupción de VBlank ocurre cuando la GPU pasa a modo VBlank. La interrupción de LCDStat puede ocurrir por varios motivos, y estos motivos pueden configurarse.
LCDStat
Uno de los registros de la GPU se llama LCDStat (igual que la interrupción) y cumple 2 funciones: Reportar el estado de la GPU, y configurar que eventos generan la interrupción «LCDStat».
Los primeros 2 bits son el modo: HBlank = 0, VBlank = 1, OAM = 2, VRAM = 3
Muchos juegos usan este dato justamente para saber en que modo está la GPU y que memoria tienen disponible. En general cuando un juego usa esto lo que hace es un loop que lee LCDStat constantemente hasta que pasa al modo esperado.
El bit 2 indica si la línea actual coincide con «LYC». LYC es «LY Compare», es un registro en el que el juego puedo escribir un número, entre 0 y 153 para que sirva de algo, y cuando la GPU avanza de línea compara la línea nueva con LYC. Si son iguales, el bit 2 de LCDStat va a tener un 1, si no, un 0. Esto también lo pueden usar los juegos para esperar a que empiece cierta línea para hacer efectos (ej, cambiar SCX y SCY) o apagar/prender la ventana y los sprites. Así por ejemplo funciona la barra de vidas y puntos en el BattleCity, o el cuadrito del puntaje en el Tennis.
Los siguientes 4 bits configuran que eventos de la GPU pueden generar la interrupción LCDStat:
Bit 3: Empieza el modo HBlank
Bit 4: Empieza el modo VBlank
Bit 5: Empieza el modo OAM
Bit 6: Empieza a procesarse la línea LYC.
No hay un bit para el modo VRAM, si un juego quiere hacer algo en el momento exacto en que la GPU entra a ese modo tiene que leer los primeros dos bits de LCDStat hasta que valgan «3».
En cualquier momento que se cumpla una de esas condiciones, si el bit correspondiente está en 1, la GPU genera la interrupción LCDStat. Algunos ejemplos ocurren al mismo tiempo (OAM y LYC, por ejemplo), pero en esos casos la interrupción se genera una sola vez.
Algo importante a tener en cuenta es que el bit 4 (VBlank) solo indica si al empezar el modo VBlank se genera la interrupción de LCDStat, no se tiene en cuenta para la otra interrupción VBlank.
Funcionamiento
Teniendo en cuenta como funcionan los modos y las interrupciones puedo explicar mejor como funciona el HiColor «mode»: El juego cambia las paletas durante los HBlank del frame, es decir, entre líneas de un mismo frame.
Eso en principio es la explicación, pero claramente hay mas detalles sino toda la explicación anterior no tuvo sentido.
En primer lugar, si bien el hardware no tiene limitaciones sobre cuántas y cuáles paletas se pueden modificar entre una línea y otra, el tiempo disponible entre dos líneas (HBlank+OAM) no es suficiente para cambiar TODOS los colores. Muchos juegos lo que hacen es actualizar solo 4 paletas de los fondos, esperan toda una línea, cambian las otras 4 paletas del fondo, esperan otra línea, cambian de nuevo las primeras 4 paletas, etc. Pero esto es solo una forma de hacerlo, algunos juegos pueden cambiar sólo de a 2 paletas por línea, o cambiar paletas cada 2 líneas, u otras variantes.
En segundo lugar, aunque las paletas de los sprites también podrían modificarse, la mayoría de los juegos (todos?) NO usan sprites con esta técnica, porque dibujar sprites en una línea hace mas largo el período de VRAM, dejando menos tiempo disponible para HBlank y por lo tanto menos tiempo para cambiar las paletas.
Otro detalle, que capaz no se notó hasta ahora, es que al cambiar una paleta entre una línea y otra, un tile puede estar usando paletas diferentes durante un mismo frame. Mejor dicho, está usando la misma paleta (ej: BGP[4]), pero los colores de la paleta al dibujar cada línea son diferentes, así que un mismo tile puede estar mostrando 64 colores distintos.
Si bien es posible cambiar también que paleta usa un tile además del color, lo que vi que hacen todos los juegos es mantener el número de paleta constante y cambiar solo los colores que es mucho mas rápido.
También por lo que vi los juegos usan las primeras 4 paletas en la mitad izquierda y las otras cuatro en la mitad derecha.
Teniendo en cuenta todo lo anterior creo que queda claro que una imagen en modo HiColor requiere mucho mas información que una imagen común. Ya no alcanza con definir Tiles, el TileMap y los atributos, ahora también hay que definir el valor de las paletas para cada línea (ej: 32 bytes mas por línea para cambiar de a 4 paletas).
Además, para que las cosas se vean bien todo tiene que estar muy bien sincronizado así los cambios de paleta ocurren en la línea y tiempo esperado.
Y como si fuera poco aunque la imagen sea estática y no haya sprites, la CPU está haiendo cosas constantemente para que se vea bien, así que es una técnica bastante demandante para la consola.
Por todo eso la mayoría de los juegos que usan esta técnica lo hacen con algunas imágenes estáticas y sólo en ciertas partes del juego, pero existe un juego que es particularmente famoso por el uso de HiColor mode: Cannon Fodder, en el que toda la presentación del juego es un video y hecho con HiColor mode (no es mi video).
Como detalle, esto de cambiar la paleta entre líneas no es algo único de GBC. Varios juegos de GB lo usan, pero obviamente solo pueden cambiar las paletas BGP, OBP0 y OBP1, y los colores disponibles son siempre los mismos 4, así que no se usa para dar mas color, sino para hacer efectos. Por ejemplo, el BombJack lo usa para el efecto del título.
Implementación
En principio cómo este «modo» no es un modo, no hay cosas puntuales para implementar, pero cómo dije antes es muy importante la sincronización y la precisión de la emulación. Lo que voy a contar son cosas que encontré que estaba haciendo mal y algún detalle muy poco documentados que encontré buscando mucho.
Cuando terminé de implementar soporte para GBC no sabía de la existencia de esta técnica y encontré un par de juegos que no se veían nada bien:
Aladdin
LEGO Island 2
Rayman
Otros, como el Tomb Raider, mostraban una pantalla completamente negra.
Cuando dibujar en SFML
El hardware de GB y GBC dibuja los pixeles de una línea directo en la pantalla durante el modo VRAM de cada línea. Mi forma de emular esto siempre fue esperar a que termine una línea completa y dibujar los 160 pixeles. Ese «dibujar» pone información de los pixeles en un array, pero no es el color final. Al terminar el frame recorro todo el array y voy convirtiendo esa información en colores RGB compatibles con un monitor moderno.
Antes de implementar soporte para GBC, esa «información de los pixeles» era simplemente un número de 0 a 3 que representaba el color final calculado para el pixel, es decir, con la paleta correspondiente (BPG, OBP0 o OBP1) ya aplicada. Para soportar GBC cambié el significado de esa información por una estructura mas compleja que contiene, entre otras cosas, el valor de ese pixel en el tile e información de la paleta a usar. Con esta solución, al terminar el frame de nuevo recorría el array con la información de los pixeles y de acuerdo a los valores y paletas, generaba el color RGB. Es mas o menos la misma idea de antes, pero en este caso la paleta todavía no está aplicada.
Lo hice así porque convertir un color de tile+paleta a RGB no es algo trivial, y porque es más rápido convertir todos los pixeles de la pantalla al mismo tiempo que 144 líneas en momentos diferentes. Es una cuestión de accesos a memoria, velocidad al iterar, etc.
Pero eso tiene un problema, que después de leer todo lo que puse arriba puede parecer obvio, pero que me costó bastante entender: Si convierto los colores todos juntos al final del frame, estoy usando los colores de las paletas de la línea 143 para todas las anteriores.
Hay que calcular el color (o al menos aplicar la paleta correspondiente) al final de cada línea.
Cambiar esto no fue difícil, tuve que agregar una referencia a GameWindow en la GPU, y al terminar una línea llamo a una nueva función que convierte sólo los pixeles de esa línea y actualiza el array de pixeles de SFML.
// En GPU.cpp
void GPU::DrawLine(u8 line) {
bool drawBG = isCGB || LCDC.bgOn;
bool drawWIN = LCDC.winOn && (isCGB || LCDC.bgOn) && line >= WY;
bool drawSprites = LCDC.spritesOn;
if (drawBG)
DrawBackground(line, drawWIN ? WX - 7 : LCDWidth + 8);
if (drawWIN)
DrawWindow(line);
if (drawSprites)
DrawSprites(line);
if (gameWindow != nullptr)
gameWindow->DrawLine(line);
}
// En GameWindow.cpp
void GameWindow::Update() {
// Antes acá ejecutaba el código de DrawLine pero para todas las líneas juntas
screenTexture.update(screenArray);
screenSprite.setTexture(screenTexture, true);
renderWindow->clear();
renderWindow->draw(screenSprite);
renderWindow->display();
}
void GameWindow::DrawLine(u8 line) {
u32 x = line * width;
u32 xMax = x + width;
for (; x < xMax; x++)
((sf::Uint32*)screenArray)[x] = gpu.GetABGRAt(x).v;
}
Y este es el resultado:
Rayman
Mmm, mejoró bastante, pero igual se ven mal…
Cuando dibujar en la GPU
Lo anterior no alcanzó para arreglar los juegos porque no es lo único importante sobre «cuándo».
Hasta ahora mi implementación era espera al final de la línea, es decir, al final de HBlank que es cuando la GPU termina por completo una línea y recién ahí dibujar los pixeles.
Eso también está mal, porque justamente durante el modo HBlank los juegos empiezan a cambiar las paletas para la línea siguiente! Al dibujar al final de HBlank estaba usando paletas pensadas para la línea siguiente en la línea actual, e incluso estaba usando paletas que estaban a mitad de un cambio. Cada paleta de GBC son 8 bytes, 2 bytes para cada color, pero durante HBlank la CPU está copiando de a un byte, no puede copiar la paleta completa. Es decir, al dibujar al final de HBlank es posible que solo algunos de los 4 colores de la paleta hayan cambiado, pero además es posible que de un color solo se haya cambiado el primer byte.
La solución a esto también es también muy fácil de implementar, en vez de llamar a DrawLine al terminar HBlank, lo llamo al terminar VRAM.
Esto en la mayoría de los juegos sigue sin ser suficiente, el resultado es mas consistente pero siguen viéndose mal.
Pero por ejemplo en el Rayman arregla por completo la presentación!
Una de las cosas que jamás iba a descubrir por mi cuenta era un detalle que no está documentado en ningún lado, solo encontré información en posts en foros (links al final). Al no estar documentado, tampoco se puede asegurar que sea 100% preciso, pero después de leer bastante al parecer está comprobado que lo que voy a contar es verificable en un GB real, y algunos emuladores (ej: BGB) parecen implementarlo así aunque el código fuente no esté disponible para verificarlo.
Cómo conté, la GPU dibuja desde la línea 0 hasta la 143 y después pasa a modo VBlank, que dura el equivalente a 10 líneas mas. Durante este tiempo la GPU actualiza el registro LY con el número de línea equivalente, es decir, de 144 hasta 153… O al menos eso es lo que dice la documentación. Antes de encontrar la solución, estuve tratando de arreglar el problema bastante tiempo. Ya había arreglado los puntos que comenté antes del tiempo en que se dibuja la línea, pero seguía teniendo problemas en varios juegos.
En algún momento haciendo pruebas se me ocurrió que capaz el problema era que las paletas estaban desfazadas, así que hice un cambio que claramente no era lógico: ignoré las primeras dos líneas del frame, pero en lugar de dejarlas en blanco, al dibujar las siguientes, le resté 2 al número de línea. De esta forma estaba dándole tiempo al juego a cargar las primeras 8 paletas durante los 2 primeros HBlank y después en lugar de dibujar la 3er línea estaba dibujando la 1ra. Al terminar la 3er línea se actualizaron las primeras 4 paletas y al dibujar la 4ta dibujo en realidad la 2da.
Y eso, por mas rebuscado que parezca, funcionó!
O mejor dicho, funcionó a medias. La imagen en el Aladdin de golpe tenía sentido, los colores, las letras, todo se veía bien, pero habían algunos problemas.
El primero, totalmente esperado, es que sin importar que juego corriera, las últimas dos líneas de la pantalla no se dibujan. Tiene sentido, para que se dibujen esas líneas necesitaba que la GPU intente dibujar las líneas 144 y 145, algo que claramente no pasa.
Después el funcionamiento era inconsistente, el Tomb Raider por ejemplo parecía funcionar bien. El Aladdin se veía mejor, pero con una parte negra. El Rayman volvió a verse mal…
Era obvio que esta «solución» mia no tenía sentido, pero me dió la pauta de algunas cosas:
Las paletas se estaban actualizando con los colores correctos
Los cambios que comenté mas arriba estaban bien implementados
De alguna manera necesitaba que las paletas para la línea 0 estén cargadas ANTES de empezar la línea 0.
La solución real es la siguiente:
Cuando la GPU empieza la última «línea» del modo VBlank, la 153, internamente ya la trata como una línea 0!!
Eso significa que los eventos son así:
La GPU entra en en modo VBlank, LY vale 144
Pasa el tiempo equivalente a 9 líneas, LY vale 152
Empieza la línea «153» PERO internamente LY pasa a valer 0
Durante este tiempo la GPU sigue en modo VBlank, NO pasa por los modos OAM, VRAM ni HBlank, por lo que tiene toda la memoria disponible.
Cuando termina la línea empieza el frame real, la GPU pasa a modo OAM y LY sigue valiendo 0
La GPU funciona normalmente para la línea 0 real y para el resto del frame
Este cambio de 152 a 0 durante VBlank puede generar la interrupción LCDStat si LYC vale 0. De esta forma un juego tiene 456 ciclos justo antes de la línea 0 real para preparar cosas, y como el tiempo es casi el doble del que tiene disponible normalmente entre línea y línea, se pueden setear las 8 paletas! Las 8 paletas que necesita para la línea 0 real!
Implementar esto tampoco fue complicado A, sólo tuve que cambiar esto:
Antes comenté que Cannon Fodder es uno de los ejemplos mas comunes del uso de HiColor, pero no puse capturas porque además de verse mal por todos los problemas que comenté, tiene OTRO problema:
En este caso el problema estaba en la implementación de HDMA. En el post anterior conté como funciona este nuevo modo de transferencia en detalle, así que voy a ir directo al error, que fue un error muy simple pero muy difícil de encontrar.
Para calcular las direcciones de origen y destino tengo que combinar 2 registros: HDMA1 y HDMA2 para el origen, HDMA3 y HDMA4 para el destino. Mi código original era:
Estoy combiando los dos registros para formar la dirección base, y sumándole el offset para copiar los bytes actuales.
El problema está en el orden de precedencia de los operadores «|» y «+». En mi código mi idea era hacer primero el OR («|») y después la suma, pero «+» tiene precedencia respecto de OR!
En algunos casos un OR da el mismo resultado que una suma y por eso hice el OR entre HDMA1 y HDMA2. Por ejemplo «0x10 | 0x01» es 0x11, es decir, es equivalente. Pero «0x01 | 0x03» es 0x03!
— Diego (taylor's theorem) (@DiegoSLTS) April 5, 2020
TileMapViewer
Algo que me confundía muchísimo cuando empecé a ver estos problemas fue lo diferente que se veían la ventana del juego y la del viewer que hice para los tilemaps.
No era solo confuso al verlo en mi emulador, noté que en BGB, que también tiene una ventana para ver los TileMaps, tiene el mismo comportamiento!
Primero traté de arreglarlo, pero al ver que en BGB se veía igual supuse que no estaba roto, y después de un tiempo entendí que está pasando.
La ventana del juego se actualiza línea por línea y está sincronizada con eventos de la GPU y CPU para usar las paletas actuales de cada linea, pero el viewer se actualiza en otros momentos. En principio en mi código se actualiza al terminar un frame completo, y como expliqué eso no sirve para ver bien los colores de HiColor mode. En BGB pasa lo mismo.
Creo que es imposible sincronizar esta ventana con la GPU y CPU para que de alguna manera se vean resultados equivalentes, así que lo dejé así, «roto».
Conclusión
Desde que agregué emulación de GameBoy Color el emulador ya llegó a un estado en el que los problemas son por bugs de la implementación, no por falta de emulación de algún componente. Los fixes parecen simples pero lleva bastante tiempo encontrar el problema, después arreglarlos suele llevar un rato. No puedo dejar de recomendar tener herramientas para debuggear, si algún dia escriben un emulador no dejen para el final cosas como una ventana para ver los Tiles, o la posibilidad de emular de a una instrucción sin depender de un breakpoint, o una forma de logear cosas en un archivo. Muchos problemas los podría haber encontrado antes si hubiese tenido estas cosas desde el principio.
Sobre la técnica de HiColor, es algo que exige mucho a la consola y por lo tanto exige mucho al emulador. Uno de los problemas con esta técnica es que hay poca información, por eso quise explicar en detalle varias cosas. Si algún dia escriben su propio emulador capaz encuentren problemas totalmente diferentes a los que tuve yo, pero espero que la explicación del principio le sirva a alguien.
El GameBoy Color (GBC) fue lanzado en 1998, 9 años después del GameBoy original, pero en lugar de hacer una consola totalmente nueva Nintendo mejoró ciertos aspectos del GameBoy para agregar, principalmente, pantalla a color.
La pantalla del GB original solo puede mostrar 4 tonos de gris, pero el GBC puede mostrar hasta 32768 colores.
Al mismo tiempo, la consola es retrocompatible con juegos de GB original monocromáticos. Esto es posible porque el hardware, en su mayor parte, se mantuvo igual. Se agregaron otras cosas además del color que solo están disponibles para juegos nuevos, y en este post voy a contar como se agregó toda esta funcionalidad.
Juegos
A partir del GBC existen TRES tipos de juegos a considerar:
Juegos hechos exclusivamente para GBC. En general cartuchos semi transparentes.
Juegos hechos para GBC Y GB. En general cartuchos negros.
Juegos hechos para GB, que funcionan también en GBC. Son casi todos los juegos del GB original, en general son cartuchos grises salvo los Pokemon y otros que se hicieron con cartuchos especiales.
Los juegos exclusivos de GBC obviamente tiene color y tienen disponible toda la funcionalidad extra del GBC. No se pueden jugar en un GB común.
Un segundo tipo de juego son cartuchos desarrollados para GBC que se ven con colores y tiene acceso a la funcionalidad nueva, pero que al usarse en GB se ven monocromáticos y obviamente funcionan sin todas esas cosas. Son juegos que se desarrollaron cuando salió el GBC pero fueron programados con las dos consolas en mente.
«Gex – Enter The Gecko» en GBC y GB
Por último, los juegos hechos para GB se pueden usar en un GBC. No tienen disponible ni usan la funcionalidad nueva de forma directa, pero en estos casos la consola se configura automáticamente en un modo llamado «Non-CGB». Este modo no es simplemente apagar funcionalidad para transformar la consola en un GameBoy viejo, Nintendo se las ingenió para AGREGARLES COLOR! Mas adelante voy a hablar en detalle de esto.
Es el mismo juego, pero con color!
Header
Como conté en alguno de los primeros posts, los juegos tienen una sección que se llama el Header que contiene datos como el nombre, el fabricante y algunas cosas mas. El header completo son los bytes desde 0x0100 hasta 0x014F, y originalmente 16 bytes (desde 0x0134 hasta 0x0143) contenían el título del juego. Cuando Nintendo hizo el GBC reusó el byte 0x0143 para especificar el tipo de juego. Si un juego de GB usa los 16 bytes del nombre, el byte 16 es el código ascii de un caracter, si no lo usa es un 0x00. Eso deja disponibles varios códigos de los 256 posibles que no se usan nunca. Los juegos de GBC tienen en este byte uno de estos dos números:
0xC0 si el juego solo funciona en GBC
0x80 si el juego funciona en las dos versiones.
Cualquier otro valor se considera un juego de GB original
En principio no hay que emular esto, es un dato que está en el juego, pero si hay que tener en cuenta este valor para decidir que consola emular. Para probar las distintas combinaciones de cartucho y consola agregué una configuración al emulador: deducir la versión (según el byte del header), forzar GB o forzar GBC.
// En Cartridge.cpp
void Cartridge::LoadHeader() {
...
header.cgbFlag = (header.title[15] == 0x80) || (header.title[15] == 0xC0);
}
bool Cartridge::IsGBCCartridge() const {
return header.cgbFlag;
}
// En GameBoy.h
enum class EmulationModeSetting {
Detect,
GameBoy,
GameBoyColor
};
// En GameBoy.cpp
GameBoy::GameBoy(...) {
// Por ahora lo cambio a mano porque no tengo settings
EmulationModeSetting emulationModeSetting = EmulationModeSetting::Detect;
switch (emulationModeSetting) {
case EmulationModeSetting::Detect:
IsCGB = cartridge.IsGBCCartridge();
break;
case EmulationModeSetting::GameBoy:
IsCGB = false;
break;
case EmulationModeSetting::GameBoyColor:
IsCGB = true;
break;
}
mmu.IsCGB = IsCGB;
gpu.IsCGB = IsCGB;
//etc
}
De acuerdo a la versión de la consola que se está emulando hay cosas que van a cambiar, por eso tengo que pasarle el bool a otros componentes.
RAM
El GBC sigue usando el mismo procesador y manejando direcciones de memoria de 16 bits. La estructura de la memoria también es la misma, con 32KB para acceder a la ROM, 8KB para la memoria ram interna y 8KB para la memoria de video.
En el post sobre MBCs conté cómo es posible que haya juegos de hasta 8 MB cuando hay solo 32KB de direcciones disponibles. Para la memoria RAM interna Nintendo hizo algo parecido, el GBC tiene en total 32KB de memoria RAM accesible a través 8KB de direccions usando bancos de memoria. El GB original tiene los 8KB de memoria RAM mapeados a 8KB de direcciones, desde 0xC000 hasta 0xDFFF. En GBC este espacio está dividido en dos, entre 0xC000 y 0xCFFF está siempre disponible el banco 0 de 4KB de RAM y el resto (de 0xD000 a 0xDFFF) se mapea a otro de los 7 bancos restantes de 4KB cada uno.
Para configurar que banco está mapeado se usa el registro 0xFF70. Al escribir un valor en esta dirección los primeros 3 bits determinan el banco, el resto no se usa. Como son 3 bits el número puede ir de 0 a 7, pero en el caso de escribir un 0 se mapea el banco 1 al igual que al escribir un 1.
// En MMU.h
// 0xC000 - 0xCFFF banco 0, 0xD000 - 0xDFFF banco N según bankNIndex
// Antes era de 0x2000 elementos
u8 internalRAM[0x8000] = { 0 };
u8 bankNIndex = 1; // [1,7]
// En MMU::Read
if (address < 0xD000)
return internalRAM[address - 0xC000];
else
return internalRAM[address - 0xD000 + bankNIndex * 0x1000];
// En MMU::Write
if (address < 0xD000)
internalRAM[address - 0xC000] = value;
else
internalRAM[address - 0xD000 + bankNIndex * 0x1000] = value;
...
if (address == 0xFF70) {
u8 bankIndex = value & 0x07;
bankNIndex = bankIndex == 0 ? 1 : bankIndex;
value |= bankNIndex; // tengo que confirmar esto
}
ioPorts[address - 0xFF00] = value;
Boot rom
El GBC tiene una boot rom diferente, se ve el logo de GameBoy además del de Nintendo y nada se mueve, pero cambia el color. Al terminar de ejecutarse el punto de entrada del juego sigue siendo 0x0100 y se pone un 1 en el bit 0 de 0xFF50 para descargar la bootrom y habilitar el acceso al cartucho, pero el estado de los registros de la CPU y de la memoria es diferente al original. Un juego puede leer el valor del registro A justo después de ejecutar la boot rom para saber si está corriendo en un GB original o en un GBC. Si vale 0x11 es GBC, si no es GB.
Los juegos de GBC compatibles con GB pueden usar este byte para saber que versión del juego mostrar:
Izquierda: Tetris DX emulando GB original. Derecha: Tetris DX emulando GBC. En GB no es simplemente una versión en blanco y negro. El fondo y otros detalles cambian.
Otros, como el Pokémon Crystal, pueden usarlo para mostrar un mensaje que explica que el juego no es compatible con el GB:
El código de la boot rom original eran 256 bytes y se encuentran varias explicaciones detalladas de lo que hace, pero el de GBC ocupa 3KB y encontré un desensamblado pero no muchas explicaciones. Se consigue la boot rom completa en un archivo, así que modifiqué el código para usar la boot rom original o la de GBC según el tipo de consola que se está emulando:
// En GameBoy.cpp, después de decidir que consola emular
mmu.LoadBootRom(IsCGB);
// En MMU.cpp
void MMU::LoadBootRom(bool isCGB) {
IsCGB = isCGB;
char* romFileName = IsCGB ? "cgb_bios.bin" : "dmg_boot.bin";
std::ifstream readStream;
readStream.open(romFileName, std::ios::in | std::ios::binary);
if (readStream.fail()) {
char errorMessage[256];
strerror_s(errorMessage, 256);
std::cout << "ERROR: Could not open bootrom file " << romFileName << " - Error: " << errorMessage << std::endl;
}
struct stat bootRomStats;
stat(romFileName, &bootRomStats);
bootRom = new u8[bootRomStats.st_size]{ 0 };
readStream.read((char*)bootRom, bootRomStats.st_size);
readStream.close();
}
u8 MMU::Read(u16 address) {
if (address < 0x8000) {
if (!IsBootRomEnabled() || (address >= 0x0100 && address <= 0x014F)) {
return cartridge == nullptr ? 0xFF : cartridge->Read(address);
} else
return bootRom[address];
}
}
...
}
MMU::~MMU() {
if (bootRom != nullptr)
delete[] bootRom;
}
Algo que tuve que tener en cuenta fue que la boot rom nueva es mas grande que 256 bytes, así que para valores mas altos también se puede estar queriendo acceder a la boot rom todavía. Tuve que cambiar un poco la condición, sino la boot rom no puede continuar. Lo importante es que al intentar accedera a la región del header si se use la rom del juego.
Double Speed
Una de las cosas nuevas no relacionadas con el color es que la CPU tiene dos velocidades disponibles, la original de 4MHz y una de 8MHz. Los juegos pueden cambiar la velocidad, idealmente deberían usar la velocidad doble sólo para las partes mas exigentes y volver a la velocidad normal cuando sea posible para ahorrar batería, pero algunos juegos la cambian justo depués de la boot rom y la dejan así siempre. Esto del uso de la batería no importa mucho en el emulador, pero es importante tener en cuenta que el juego en cualquier momento puede hacer el cambio, no es algo fijo.
La forma en que se cambia el modo es medio particular:
El juego chequea la velocidad a la que está funcionando leyendo la dirección 0xFF4D, si el bit 7 vale 0 está usando la velocidad normal, si es 1 está usando la doble.
Si el juego quiere cambiar la velocidad escribe un 1 en el bit cero en la misma dirección. Esto no hace que la velocidad cambie, pero después…
El juego ejecuta la instrucción STOP.
Originalmente esta instrucción frena la CPU hasta que se genere una interrupción, pero en el GBC si se llama después de setear el bit de 0xFF4D se produce el cambio de velocidad y el juego NO se frena. ¿Qué velocidad se pone? «La otra», por eso el juego primero tiene que chequear en cual está.
En principio implementar esta lógica no fue difícil:
void CPU::STOP() {
u8 key1 = mmu.Read(0xFF4D);
if ((key1 & 0x01) == 1) {
if (isDoubleSpeedEnabled) {
isDoubleSpeedEnabled = false;
key1 &= 0x7F; // bit 7 en 0
} else {
isDoubleSpeedEnabled = true;
key1 |= 0x80; // bit 7 en 1
}
mmu.Write(0xFF4D, key1 & 0xFE);
} else
isHalted = true;
}
Pero no es tan simple como que ahora las cosas pasan el doble de rápido. Este nuevo modo afecta sólo ciertos componentes:
CPU (Obviamente)
Timers
DMA
Transferencias por link cable (nunca implementé esto en el emulador)
El resto de los componentes (GPU, Audio, etc) funcionan a la misma velocidad que antes, así que hay que hacer un cambio en como se actualizan los componentes según los ciclos emulados:
O sea, si la CPU está funcionando al doble de velocidad, la cantidad de ciclos de CPU emulados implica la mitad del tiempo real. Las cosas que tienen que andar a mayor velocidad (DMA, Timer) siguen igual, las cosas que tienen que andar como antes andan «mas lento». Cómo todo está sincronizado con las muestras de audio lo que termina pasando es que para generar una muestra se emula el doble de tiempo de CPU.
Algo interesante de esto es que como es un feature opcional, algunos juegos de GBC funcionan aunque no esté emulado o esté emulado a medias. Simplemente con no bloquear la CPU al llamar a STOP varios juegos dejaron de quedarse bloqueados en el inicio. Algunos juegos como los Grand Theft Auto incluso parecen funcionar aunque no esté el modo implementado, pero obviamente funcionan mas lento de lo que deberían.
GPU
La GPU es el componente con mas cambios, así que voy a ir en detalle.
VRAM
El GBC tiene el doble de memoria de video que el original, 16KB, pero no son accesibles al mismo tiempo. Se usa un mecanismo de bancos parecido al de los juegos y la RAM que conté antes, pero en este caso no hay un banco 0 siempre disponible. El rango completo (0x8000–0xA000) está mapeado al banco 0 o al 1, de 8KB cada uno, y eso se cambia escribiendo un 0 o un 1 en el bit 0 en la dirección 0xFF4F.
// En GPU.h
u8 videoRAM0[0x2000] = { 0 }; // videoRAM de GB, renombrado
u8 VideoRAM1[0x2000] = { 0 }; // Sólo se usa en GBC
u8 VRAMBank = 0; // Sólo se modifica en GBC
// En GPU.cpp
void GPU::Write(u8 value, u16 address) {
if (address >= 0x8000 && address < 0xA000) {
if (VRAMBank == 0)
videoRAM0[address - 0x8000] = value;
else
VideoRAM1[address - 0x8000] = value;
}
switch(address) {
...
case 0xFF4F:
VRAMBank = (value & 0x01); break;
...
}
}
void GPU::Read(u8 value, u16 address) {
if (address >= 0x8000 && address < 0xA000) {
if (VRAMBank == 0)
return videoRAM0[address - 0x8000];
else
return VideoRAM1[address - 0x8000];
}
switch (address) {
...
case 0xFF4F:
return VRAMBank | 0xFE;
...
}
return 0xFF;
}
En los posts sobre GPU conté como está dividida la memoria de video, y todo eso sigue aplicando para el banco 0. Una parte de la memoria (0x8000–0x97FF) tiene la información de cada Tile, describe pixel por pixel 384 tiles de 8×8. El resto (0x9800–0x9FFF) contiene la definición de 2 TileMaps, cada uno de 32×32 bytes que indican que Tile de la primer región dibujar en cada área de 8×8 pixeles de la pantalla, y se usa para el fondo (BG) y para la «ventana» (WN).
El banco 1 tiene espacio para mas Tiles, también entre 0x8000 y 0x97FF, y el resto también está conceptualmente dividido en 2 grupos de 32×32 bytes que se relacionan con regiones de 8×8 pixeles en la pantalla, pero la información de estos bytes no es un índice de un Tile como en el banco 0, es un byte con atributos (parecido al de los sprites):
Bits 0-2: un número de paleta
Bit 3: en que banco de VRAM están los datos del Tile
Bit 4: No se usa
Bit 5: espejar horizontalmente (0 = no, 1 = si)
Bit 6: espejar verticalmente (0 = no, 1 = si)
Bit 7: prioridad
Ahí se puede ver una nueva funcionalidad además del color, los tiles que se dibujan en el fondo y la ventana ahora también pueden espejarse! Antes esto sólo se podía con los sprites, que ya tenían su propia sección de memoria con atributos. De la paleta y la prioridad voy a hablar mas adelante.
Para que quede mas claro hice este diagrama con la información y como la usa la GPU:
No se usar otro programa que no sea el Paint para dibujar 😛
O sea, el juego mapea un banco u otro en ciertos momentos, pero la GPU no usa «el banco mapeado», usa siempre los 2 y siempre de la misma forma. El mapeo es solo para poder configurar el estado de cada banco.
Sprites
Para los sprites hay sólo 2 cambios en el byte de atributos que ya existe en la sección de OAM (0xFE00-0xFE9F):
Bit 3: define a que banco hace referencia el índice del tile, igual que con los fondos
Bits 0-2: definen la paleta igual que con los fondos
El resto de los bits siguen funcionando igual, aunque el bit de la paleta vieja (bit 4) sólo se usa cuando el juego es de GB.
Paletas
Las paletas son 4 colores que se usan en lugar de los valores entre 0 y 3 que forman cada tiles, cambiando la paleta se puede cambiar por completo como se ve un tile sin modificar la información, como conté en los posts de GPU.
El GB original tiene un registro donde se define una paleta para BG y WIN (0xFF47 o «BGP») y 2 donde se definen 2 paletas para los sprites (0xFF48 y 0xFF49, o «OBP0» y «OBP1»). Todos los tiles del fondo usan la misma paleta salvo que el juego cambie el valor. Para los sprites cada sprite usa una paleta de las dos disponibles, pero para tener mas variedad el juego tiene que modificarlos seguido.
Para GBC además de agregar soporte para colores agregaron la posibilidad de configurar 8 paletas para los fondos y otras 8 para los sprites.
Pero esas paletas no están mapeadas a un rango de direcciones, no son parte de los bancos 0 y 1 de la VRAM ni otra sección del espacio de direcciones. En total son 128 bytes mas, 64 para cada grupo de paletas, 8 bytes para cada paleta, 2 bytes para cada color. Las paletas de los fondos se leen y escriben de una forma particular:
El registro 0xFF68 es un índice a la memoria de las paletas de los fondos, o sea, un número entre 0 y 63. Este valor se puede escribir y leer en cualquier momento.
Para escribir un dato en la memoria de paletas de fondos se escribe el valor en el registro 0xFF69, y ese byte va a parar a la memoria de paletas en el índice que se escribió en 0xFF68.
Si se lee el valor de 0xFF69 el resultado es el valor de la paleta en el índice dado por 0xFF68.
Entonces el juego configura un índice y escribe un valor y así va cargando la o las paletas. Pero hacer esto para configurar las 8 paletas requeriría 64 escrituras a cada dirección y en general es muy común querer configurar todas las paletas juntas, así que hay un detalle mas que facilita esto para cuando se quieren configurar varias paletas de corrido.
El registro 0xFF68 necesita solo los primeros 5 bits para definir el índice, pero si además se pone el bit 7 en 1, cada escritura a 0xFF69 incrementa el índice automáticamente. De esta manera si el juego pone el valor 0x80 en 0xFF68 (índice = 0, auto incremento = sí), después puede completar todas las paletas escribiendo 64 valores en 0xFF69, porque le índice avanza solo. Son 65 escrituras en lugar de 128!
// En GPU.h
u8 BGPI = 0; // 0xFF68
u8 BGPMemory[64] = { 0 }; // a través de 0xFF69 según BGPI
// En GPU.cpp
u8 GPU::Read(u16 address) {
...
switch (address) {
...
case 0xFF68:
return BGPI;
case 0xFF69:
return BGPMemory[BGPI & 0x3F];
...
}
}
void GPU::Write(u8 value, u16 address) {
...
switch (address) {
...
case 0xFF68:
BGPI = value & 0xBF; break;
case 0xFF69:
BGPMemory[BGPI & 0x3F] = value;
if (BGPI & 0x80)
BGPI++;
break;
...
}
}
Para las paletas de sprites es exactamente igual, pero con los registros 0xFF6A y 0xFF6B. La única «diferencia» es que de los 4 colores de la paleta, el primero (00) nunca se usa, porque en los sprites el color 00 siempre es transparente aunque la paleta tenga un color configurado. Los bytes están, se pueden escribir y leer, pero no sirven de mucho.
Volviendo a los atributos del banco 1 de la VRAM y a los cambios en los atributos de Sprites que nombre en la sección anterior, los bits 0-2 que definen «un número de paleta» justamente definen un número de 0 a 7 que es cual de las 8 paletas usa. No es un índice a la memoria de paletas! Si todavía es confuso, cuando muestre como lo implementé va a quedar mas claro.
Colores
Cada paleta tiene codificados 4 colores RGB en 8 bytes, o 1 color RGB cada 2 bytes. Es decir, que la paleta 0 está en el índice 0 de la memoria de paletas, la paleta 1 en el índice 8, la 2 en el , etc. Y dentro de una paleta, el color 0 son los primeros 2 bytes, el 1 son los siguientes 2, etc.
De estos 2 bytes sólo se usan los primeros 15 bits. Lo importante de esto es que el primero byte son los primeros 8 bits (mas bajos), y el segundo byte son los siguientes 7 (mas altos).
Esos 15 bits tienen las componentes R, G y B del color, 5 bits para cada uno, por lo que pueden tener cada uno un valor en el rango [0,31] dando un total de 32x32x32 = 32768 colores diferentes.
LCDC y Prioridades
Tanto en el GB como en el GBC el registro 0xFF40 se llama «LCDC» o «LCD Controller» y sirve para configurar varias cosas cómo el tamaño de los sprites o si se dibujan o no el fondo y la ventana. Casi todo funciona igual, salvo el bit 0.
En GB (o en GBC corriendo un juego de GB (*)) si el bit está en 0, fondo y ventana NO se dibujan, sólo se dibujan sprites. Si está en 1 se dibujan, y después los sprites pueden o no dibujarse encima según una prioridad.
En juegos de GBC, si está en 0, fondo y ventana se dibujan, pero los sprites pasan a tener mayor prioridad automáticamente, se dibujan siempre arriba del fondo. Si vale 1 se consideran varias cosas al momento de definir la prioridades de los sprites. La diferencia importante es que en este caso el fondo no se puede apagar! La ventana si, pero usando otro bit de LCDC.
Además de este bit, hay un bit en los atributos de los tilemaps (solo en GBC) y en los atributos de los sprites (en todos los modelos) que define una «prioridad», ¿pero que significa todo esto? Los juegos actuales 2D pueden definir un orden «Z» para saber que se dibuja delante de que, y pueden también definir transparencias. En el GB no se pude hacer eso de forma tan directa, por eso el juego puede definir prioridades para, junto con otras definiciones del hardware, dibujar una cosa sobre otra.
Los tiles de un mismo tilemap no se pueden superponer, así que en ese sentido no aplica el concepto. La ventana se superpone siempre con el fondo, pero siempre se dibuja arriba, así que en ese sentido tampoco aplica. Todo se usa para determinar si un pixel de un sprite se dibuja o no en un lugar, de acuerdo a la prioridad y el origen del pixel que ya está dibujado. Los sprites siempre se dibujan después de los fondos o después de otros sprites.
Las reglas en total son varias y se aplican en lugares ligeramente diferentes, pero hay mas o menos un orden. Tiene sentido separar el tema en 2, GB o GBC en modo Non-CGB por un lado, y GBC por otro. Pero antes quiero recordar que para los sprites el color 00 (antes de aplicar la paleta) siempre es transparente, así que en esos casos no se modifica el pixel actual.
En GB o GBC en modo Non-CGB sólo existe el bit de prioridad de los Sprites y una prioridad automática del hardware, así que veamos que pasa en las dos situaciones posibles:
El pixel actual es de un fondo:
Si el bit es 0, el pixel se dibuja
Si el bit es 1, el pixel se dibuja SOLO si el pixel actual es de color 00
Si el pixel actual es de otro sprite el bit de prioridad no se usa:
Si el Sprite tiene una posición «X» menor a la del pixel actual, lo pisa
Si el Sprite tiene una posición «X» mayor a la del pixel actual, se ignora
Si los Sprites tienen la misma posición «X», el que está primero en la memoria OAM tiene prioridad.
Esto último implica que antes de dibujar los sprites de GB los tengo que ordenar en una lista temporal según si X.
Para GBC existen 3 bits de prioridad: el del Sprite, el del fondo y el de LCDC.0, y además la prioridad del hardware que es un poco diferente a la de GB:
El pixel actual es de un fondo:
Si LCDC.0 es 0, el sprite se dibuja arriba del fondo, se ignoran los otros bits de prioridad
Si LCDC.0 es 1 y el fondo tiene prioridad (bit 3 en 1), el sprite NO se dibuja
Si LCDC.0 es 1 y el fondo NO tiene prioridad (bit 3 en 1), se usa la prioridad del Sprite igual que en GB
El pixel actual es de un Sprite:
No se prioriza por posición en «X», simplemente el sprite que está primero en la memoria OAM tiene prioridad.
En GBC NO tengo que ordenar la lista de sprites.
El código de todo esto lo voy a poner mas abajo porque son muchas cosas repartidas por distintos lados y creo que es muy confuso.
(*) Sobre esto encontré información contradictoria, así que no estoy seguro:
Tratando de emular bien el tema de las prioridades encontré que tenía que modificar algo bastante importante que hasta el momento venía ignorando.
Hasta ahora para GB monocromático, para dibujar la pantalla espero todo el tiempo que tarda en dibujarse una línea completa y recién ahí dibujo los 160 pixeles de esa línea (el fondo, la ventana y los sprites). El resultado de debujar todas las líneas es un array de 160*144 bytes con el color en el formato de gameboy (00, 01, 10 o 11) con la paleta ya aplicada, y después en GameWindow simplemente transformo ese valor a un valor entre 0 y 255 para cada componente. Esto tenía un error, porque para saber si un sprite se dibuja sobre otro pixel tengo que saber el color ANTES de aplicar la paleta, pero yo solo estaba escribiendo el color DESPUÉS de aplicarla.
A la izquierda Samus no se llega a dibujar. A la derecha hay un problema diferente, no lo investigué todavía.
Para agregar color mi primer intento fue tener un array diferente, también de 160*144 elementos, pero con enteros de 2 bytes, para guardar los 15 bits del color después de aplicar la paleta. Esto funcionaba hasta que me puse a revisar las prioridades, tiene el mismo problema que comenté antes: Necesito saber si el pixel actual es «0» o no, pero no el color negro, sino el color 0 de la paleta. Pero además necesito saber si ese pixel es del fondo o de otro sprite, y no tenía esa información.
Para arreglar esto tuve que implementar hasta cierto punto algo que venía evitando.
La GPU real de la consola tiene lo que se llama un «Pixel Pipeline» y está generando de a un pixel en la pantalla cada un par de ciclos. La explicación mas detallada que encontré del tema es en The Ultimate GameBoy Talk, pero cuando intenté implementarlo bajó mucho la performance.
Mas allá de que en mi código sigo esperando todo el tiempo de una línea, modifiqué la información que se guarda en el array de 160*144 bytes y pude usar un sólo array para la emulación de GB y GBC.
En vez de guardar un color con la paleta ya aplicada, cada índice guarda ahora esta información:
Bits 0-1: El índice del color en la paleta [0,3]
Bit 2: 1 si el pixel es del fondo, 0 si es de un sprite
Bits 3-5: El índice de la paleta en GBC [0,7], el número de paleta del sprite en GB si el pixel corresponde a un sprite [0,1] o 0 si el pixel es del fondo
Bit 6: No lo uso
Bit 7: 1 si el fondo tiene prioridad (sólo se usa en GBC)
La idea de esto es guardar la información equivalente a la que se ve en el vídeo que comenté antes. Mas adelante voy a mostrar el código completo de la GPU y como usé esta información. Es mas simple de lo que parece.
Modo Non-CGB
Como ya comenté varias veces los juegos de GB se pueden jugar en un GBC, y si bien no tienen acceso a features nuevos ni al uso de color de forma directa, la consola entra en un modo llamado «Non-CGB» que le agrega color a los juegos. No es simplemente un tinte, es algo mas complejo e incluso configurable por el usuario.
Los juegos de GB no puede acceder a las nuevas paletas de colores, ni para leerlas ni para escribirlas, pero esto no quiere decir que las paletas estén vacías. La bootrom tiene que configurar paletas para mostrar el logo y los colores, pero además cuando entra a modo Non-CGB tiene una funcionalidad medio «escondida». Por un lado hace algo automático: Según e juego selecciona automáticamente una paleta de una tabla interna, por eso algunos juegos como el Tetris usan blanco, amarillo, rojo y negro, y otros como el Link’s Awakening usan verde y un rosado. Pero además, si el jugador mueve el dpad en alguna dirección y aprieta o no A y B la bootrom pasa a usar otras paletas para los fondos y sprites. Cómo después el juego no tiene acceso para modificarlas, las paletas se mantienen constantes mientras se juega.
En wikipedia está la tabla completa de inputs y configuraciones. Y así cambian los juegos con la paleta por defecto según su hash:
Pokémon Blue
Tetris (NO DX)
Link’s Awakening (NO DX)
¿Cómo funciona? Cuando se va a dibujar el juego de GB, primero se aplica la paleta original (BGP para los fondos, OBP1/2 para los sprites). Eso produce otra vez colores de 0 a 3 pero en otro orden, y a ese resultado se le aplica una paleta de GBC (la 0 para los fondos, la 0/1 para los sprites).
La implementación de como se aplican las paletas es sencilla pero hay que tener en cuenta un detalle importante: hay que aplicar solo las 2 paletas cuando la consola entra en modo Non-CGB, y eso no pasa al principio de la secuencia de boot. Mi primera implementación de esto fue tener un booleano «isInNonCGBMode», si estaba emulando una GBC jugando un juego de GBC lo ponía en falso, y sino en true. Pero esto tiene un problema muy evidente al ejecutar el juego, los colores durante la boot rom están mal, porque está usando las paletas de GB sin inicializar.
La forma en que funciona esto no está muy documentada, pero la bootrom escribe un «4» en la posición de memoria 0xFF4C para iniciar el modo Non-CGB, justo antes de descargarse e iniciar la ejecución del juego. Antes de iniciar el modo, la boot rom ya tiene en cuenta el tipo de cartucho (por el byte 0x0143) y permite seleccionar la paleta con el dpad, pero todavía el modo no está seteado. Una vez que se inicia el modo y la boot rom se descarga, no se puede desactivar a menos que se reinicie la consola.
Una de las paletas que se pueden elegir, Izquierda+B, es en escala de grises, por si a uno/a no le gusta como quedan las distintas paletas con colores.
Puerto infrarojo
Esto no lo emulé, pero pongo la sección para dejar recopilado en un sólo post todo lo que se agregó y modificó en el GBC. Sirve para comunicar 2 consolas de forma inalámbrica, no me pareció muy útil dedicarle tiempo a esto. Capaz algún dia me decida a agregar soporte para Link Cable y agrego esto también.
Emulación
Juntando toooodo lo anterior tuve que modificar la GPU para tener en cuenta los nuevos bancos, atributos, paletas y prioridades. Son menos cambios de lo que parece:
Un problema que encontré al querer transformar los colores de 15 bits a colores de 32 bits es que para transformar cada componente del rango [0,31] al [0,255] hay que multiplicar por un float (255/31 ~= 8.22) y como es algo que se hace unas 70 mil veces por frame (160*144 pixeles, 3 componentes) por ahora use un 8 entero, que da valores en el rango [0,248] y se ve aceptable. Pero leyendo sobre esto primero encontré esta advertencia: https://gbdev.gg8.se/wiki/articles/Video_Display#RGB_Translation_by_CGBs
O sea, mas allá de los valores de RGB por separado, por el tipo de pantalla del GBC y de los monitores actuales, el resultado de una conversión directa no es equivalente, pero no aclara como convertirlos.
En particular en la sección de GameBoy Color pone un cálculo que da un resultado mas fiel al original. Todavía no implementé esto pero lo tengo planeado y quería aprovechar para contar todo en un sólo post. byuu, uno de los emuladores de Near tiene una opción para habilitar esta correción de colores.
Cuando haga eso también planeo hacer algo parecido para los juegos de GB. Estoy emulando todo con blanco, negro y dos grises, pero la pantalla original es un poco mas verde, así se pueden reemplazar esos colores por unos mas verdozos y con menos contraste para emular de forma mas fiel la experiencia real. BGB permite elegir varias paletas.
HDMA
Otra funcionalidad nueva de GBC es otra versión de DMA. No es un reemplazo de la anterior, es otro mecanismo parecido.
DMA sirve para copiar muchos bytes de memoria de una lado al otro sin usar la CPU. Si no existiera DMA, para copiar 80 bytes de un lado a otro habría que llamar muchas instrucciones que lean y escriben los datos, ocupando la CPU todo ese tiempo.
Pero el DMA original tiene un uso muy específica, copia 160 bytes de algún lado de la memoria al rango [0xFE00,FE9F] y se usa para configurar los atributos de los sprites, la memoria OAM.
La nueva versión de DMA (HDMA) se usa para copiar una cantidad configurable de bytes desde la ROM o RAM hacia la VRAM. Esto se puede usar para copiar Tiles, TileMaps o atributos mas rápido sin usar instrucciones de la CPU igual que con DMA. La transferencia se hace el banco que esté configurado en 0xFF4F.
Para usar DMA simplemente hay que escribir un número en una posición de memoria y la transferencia empieza en paralelo y copia 160 bytes, el uso es bastante simple y la implementación también.
HDMA es mas complejo, primero hay que configurar la dirección de origen de los datos, después la de destino, y después iniciar la transferencia especificando la cantidad de bytes a copiar y el «modo».
Para la dirección de origen se usan 2 bytes: 0xFF51 y 0xFF52. Estos dos bytes forman una dirección de 16 bits, pero los bits 0-3 se ignoran.
Para la dirección de destino se usan otros 2 bytes: 0xFF53 y 0xFF54. Estos dos bytes forman una dirección de 16 bits, al igual que antes los bits 0-3 se ignoran, pero también se ignoran los bits 13-15, porque la dirección tiene que ser siempre dentro de VRAM.
Después de configurar las direcciones se inicia la transferencia y se indica la cantidad de bytes a transferir en una misma operación, escribiendo un valor en 0xFF55.
Los primeros 7 bits definen un número «n» en el rango [0x00, 0x7F] y se usan en este cálculo para determinar la cantidad de bytes a copiar:
cantidad = (n + 1) * 16
Es decir que la cantidad siempre es un múltiplo de 16, y el total de la transferencia puede ser entre 16 y 2048 bytes.
El bit 7 de 0xFF55 indica el modo de transferencia:
0: La transferencia inicia automáticamente y se transfieren todos los bytes juntos
1: La transferencia se hace de a 16 bytes cada vez que la GPU entra en HBlank, así que tarda bastante mas.
Para entender por que existen dos modos falta un dato mas: A diferencia de DMA que la transferencia se hace en paralelo al funcionamiento del programa, con HDMA mientras se transfieren datos la CPU NO ejecuta instrucciones! Entonces los juegos pueden usar un modo u otro según el momento del juego en el que tienen que hacer la copia, o según la cantidad de bytes que tienen que copiar. Mas allá de que la CPU no está disponible mientras HDMA está copiando, igual la operación es mas rápida que copiar con instrucciones de CPU todos esos bytes.
Algunos juegos usan el modo 0 al principio mientras el juego arranca para copiar mucha información, porque si se generan pausas no afectan al usuario, y después durante el juego usan el modo 1 para distribuir la copia un poco mas.
La implementación de esto me quedó así:
// En DMA.cpp
void DMA::StepHDMA() {
if (!hdmaInProgress)
return;
u16 length = 0x10;
u16 source = ((HDMA1 << 8) | (HDMA2 & 0xF0)) + (hdmaProgress * 0x10);
u16 dest = 0x8000 + (((HDMA3 & 0x1F) << 8) | (HDMA4 & 0xF0)) + (hdmaProgress * 0x10);
for (u16 i = 0; i 0) {
remaining--;
hdmaProgress++;
} else {
remaining = 0x7F;
hdmaInProgress = false;
}
}
u8 DMA::Read(u16 address) {
switch (address) {
case 0xFF46: // DMA original
return addressBase >> 8;
case 0xFF51:
return HDMA1;
case 0xFF52:
return HDMA2;
case 0xFF53:
return HDMA3;
case 0xFF54:
return HDMA4;
case 0xFF55:
{
u8 bit7 = hdmaInProgress ? 0 : 0x80;
return bit7 | remaining;
}
}
return 0xFF;
}
void DMA::Write(u8 value, u16 address) {
switch (address) {
case 0xFF46: // DMA original
currentCycles = 0;
addressBase = value << 8;
break;
case 0xFF51:
HDMA1 = value; break;
case 0xFF52:
HDMA2 = value; break;
case 0xFF53:
HDMA3 = value; break;
case 0xFF54:
HDMA4 = value; break;
case 0xFF55:
if (hdmaInProgress) {
if ((value & 0x80) == 0) {
hdmaInProgress = false;
}
} else {
if ((value & 0x80) == 0) {
u16 length = ((value & 0x7F) + 1) * 0x10;
u16 source = (HDMA1 << 8) | (HDMA2 & 0xF0);
u16 dest = 0x8000 + (((HDMA3 & 0x1F) << 8) | (HDMA4 & 0xF0));
for (u16 i = 0; i = 172) {
SetMode(GPUMode::HBlank);
modeCycles -= 172;
dma.StepHDMA();
}
...
}
Algunos detalles:
Cuando una transferencia termina el valor de 0xFF55 es «0xFF», los juegos pueden chequear este dato para saber si la transferencia terminó.
Si la transferencia no terminó, 0xFF55 contiene la cantidad de bloques de 16 bytes que faltan copiar.
Se puede frenar una transferencia que se inició en modo 1 escribiendo un 0 en el bit 7 de 0xFF55.
No emulé correctamente los tiempos, no estoy frenando la CPU mientras se hacen las transferencias. Por el momento desde el punto de vista de la CPU, HDMA funciona instantáneamente y eso es algo que tengo que arreglar mas adelante.
Save/Load state, SkipBoot
Al agregar mas memoria RAM y VRAM y otros registros nuevos, el código que guarda y carga el estado del emulador ya no funciona correctamente, así que tuve que arreglarlo, porque además de usarlo para saltearme la sequencia de boot, también se usa para guardar el estado en los juegos que tienen batería. No hay muchos cambios, básicamente hay que agregar nuevos arrays y algunas otras variables a los Save y Load.
También hay que tener dos archivos «BootState» diferentes, uno para cada versión, para poder saltear la rom y dejar los registros en el estado correcto.
Viewers
Todas las herramientas que programé para debuggear el juego (Tiles, TileMaps, Sprites) dejaron de funcionar al modificar la GPU, así que tuve que arreglarlas, y aproveché para mejorarlas un poco.
Tiles
Los tiles se definen entre las direccione 0x8000 y 0x97FF, pero ahora hay 2 bancos de memoria disponibles con tiles. Acá podía hacer dos cosas:
Agrandar la ventana para mostrar los tiles de los dos bancos, o…
Mostrar los tiles de un solo banco, pero agregar una forma de elegir cual banco mostrar.
Me decidí por la primera que creo que es la forma mas simple de las 2 y además es consistente con juegos de GB que no tienen un segundo banco. Además de eso decidí agregar soporte para cambiar la paleta que se está usando ahora que hay tantas opciones.
PageUp y PageDown cambian la paleta. «Fin» alterna entre bancos 0 y 1
TileMaps
Estos hay que adaptarlos porque tienen que tener en cuenta las nuevas paletas y el flip según los datos en el banco 1 de VRAM. Para esto podía hacer algo parecido al TileViewer y permitir elegir que TileMap mostrar, pero me pareció mas útil mantener los 2 a la vista.
Sprites
Acá tuve que arreglar el soporte para el banco de VRAM y la paletas según los nuevos atributos.
PaletteViewer
Todavía no lo hiece esto, pero planeo agregar una ventana para ver todas las paletas, parecida a la que tiene BGB. Si bien se pueden elegir paletas en el TileViewer, no es posible saber a que índice corresponde cada color y a veces hace falta al debugear.
Conclusión
Al final implementar el color fue mas fácil de lo que creía en ciertos aspectos y mas dificil de lo que creía en otros. Me llevó bastante tiempo y todavía muchos juegos no andan realmente, así que no lo considero como algo terminado, pero quería escribir el post con todo lo que aprendí, el resto probablemente sean errores mas chicosy mas sutiles, pero si arreglo cosas interesantes capaz hago un post mas con una recopilación de detalles.
Esto es el último de los features grandes a emular, me faltan cosas como la comunicación por Link Cable que no está en mis planes. En los próximos posts probablemente hable de unas cosas mas de audio y seguramente haga uno sobre optimizaciones si encuentro cosas interesantes. Después de eso no tengo planes para otros posts, capaz hago updates mas cortos, o capaz arranco con otro emulador? Ni idea. Se que quiero terminar de arreglar bugs y agregar funcionalidad para que algún dia sea usable por otras personas.
Espero que todos estos posts le sirvan a alguien en algún momento. Yo aprendí MUCHÍSIMO haciendo este proyecto pero también documentando el trabajo que hice. Si alguien leyó hasta acá y está haciendo su propio emulador me gustaría que me cuente. Saludos!
La idea de este post es completar todos los canales, primero con el resto de features de los canales 1 y 2, y después agregar por completo los canales 3 y 4.
Canales 1 y 2
Duración
Lo primero y mas fácil de agregar es la duración del sonido. En la parte 1 comenté que el sonido empieza a sonar cuando se lo habilita escribiendo un 1 en el bit 7 de cierta posición de memoria, pero que no es posible deshabilitarlo escribiendo un 0 en el mismo lugar.
Una de las formas de deshabilitarlo es configurando una duración. Para esto se define, como en casi todo lo que comenté hasta ahora, un número que se usa en un cálculo que determina la duración. Los registros que se usan son 0xFF11 para el canal 1 y 0xFF16 para el 2, los mismos donde se define el Duty Cycle. Los bits 6 y 7 definen el Duty Cycle, los bits del 0 al 5 definen un valor «L» entre 0 y 63. Este es el cálculo que se hace:
duración = (64 – L) / 256 segundos
Es decir, el canal suena entre 1/256 ~= 0,0039 segundos y 64/256 ~= 0.25 segundos. Mientras mas bajo el número, mas largo el sonido.
Todos los valores posibles de L determinan una duración, pero es posible NO usar este parámetro y que el canal suene hasta frenar por otros motivos. En el mismo byte que se usa para habilitar un canal (0xFF14 y 0xFF19) el bit 6 indica si se tiene que usar o no el valor de duración.
Entonces, para reinterpretar estos valores hice esto:
Antes de seguir viendo mas funcionalidad quiero comentar una forma mucho mas cómoda de testear estas cosas que jugar a un juego con música y comparando con BGB.
Hay mucho software hecho para GameBoy que se consigue en internet y funciona en emuladores como cualquier juego. En particular para sonido hay desde algunos que sirven para probar todas las configuraciones de cada canal hasta programas para componer música como «Little Sound DJ«, que hay músicos que lo usan hoy en dia para componer.
Por el momento voy a usar sound_demo.gb, aunque en algún momento espero que funcione los suficientemente bien como para emular Little Sound DJ y así escuchar canciones famosas en 8 bits.
Ese rom es un programa muy simple de usar, con Select se cambia de pantalla, cada pantalla tiene todos los parámetros configurables de 1 canal, mas una pantalla al final con unos controles globales. Con las flechas se mueve el cursor por las opciones y se cambian los valores, y con Start empieza a sonar el canal.
Por ejemplo, en esa captura del canal 1 se puede configurar el Duty Cycle en «Pat Duty», la duración en «Sound Len», el volumen en «Env Init», la frecuencia en «Frequency», y el bit para indicar si se usa o no la duración en «Cons Sel».
Por supuesto también es posible (y muy útil) comparar los resultados usando BGB. Creo que a los 5 minutos de probar sonidos con sound_demo en lugar de juegos puntuales arreglé dos problemas en la emulación, así que no puedo dejar de recomendarlo.
Envelope Sweep
Retomando features, cuando hablé de volumen comenté que se podía hacer algo más que definir un volumen constante. En realidad el volumen como lo expliqué en la parte 1 es el volumen inicial, y se puede configurar que aumente o disminuya y que tan rápido lo hace.
En la documentación lo nombran de diferentes formas: «Envelope», «Envelope Sweep» y «Volume Sweep».
Estos nuevos parámetros se configuran en el mismo registro que el volumen incial (0xFF12 para el canal 1, 0xFF17 para el 2). 4 bits ya se usaban para definir el volumen de 0 a 15, el bit 3 se usa para la dirección (1 aumenta, 0 disminuye) y los bits del 0 al 2 definen un valor «n» entre 0 y 7 que determina cada cuánto tiempo cambia el volumen. Como con casi todo, ese valor «n» se usa en un cálculo:
período = n / 64 segundos
Esto quiere decir que cada n/64 segundos el volumen aumenta o disminuye en 1, de forma lineal. Si el volumen baja hasta llegar a 0 o sube hasta llegar a 15. Un ejemplo sería algo así:
El volumen inicial era 15 y baja 15 veces hasta 0 a intervalos regulares.
bool Audio::Step(u8 cycles) {
float delta = deltaTimePerEmulatedCycle * cycles;
elapsedTime += delta;
if (channel1.Enabled) {
channel1.ElapsedTime += delta;
if (channel1.EnvelopeSweepStep > 0.0f) {
channel1.EnvelopeTime += delta;
if (channel1.EnvelopeTime >= channel1.EnvelopePeriod) {
if (channel1.VolumeDecrease && channel1.Volume > 0)
channel1.Volume--;
else if (!channel1.VolumeDecrease && channel1.Volume < 15)
channel1.Volume++;
channel1.EnvelopeTime -= channel1.EnvelopePeriod;
}
}
}
// Lo mismo para el canal 2
if (elapsedTime >= sampleTime) {
elapsedTime -= sampleTime;
sample = GetSample();
return true;
}
return false;
}
Es imporante tener en cuenta que la configuración de duración y envelope se pueden usar al mismo tiempo, pero que una puede apagar el canal antes que la otra. Si el volumen baja a 0 muy rápido el sonido no va a durar lo que se configuró en la duración, y si baja muy lento puede que se apague por duración antes de llegar a 0.
En sound_demo.gb se configura en Env Init, Env Mode y Env Nb Swp.
Canal 1
Frequency Sweep
Con lo anterior el canal 2 está emulado completamente! Pero el canal 1 tiene una funcionalidad más, parecida a la de Envelope del volumen, pero que se aplica a la frecuencia. Es un poco mas compleja de configurar, pero no tanto. Al igual que con el envelope la frecuencia se cambia cada cierto tiempo, pero el cambio no es simplemente sumar o restar 1.
Se configura todo en el byte de la posición 0xFF10. El bit 3 es la dirección, 0 para que la frecuencia aumente, 1 para que disminuya. Los bits del 4 al 6 definen un número «n» entre 0 y 7 que determina el período al que se cambia la frecuencia (como siempre, se usa en un cálculo). Los bits 0 a 2 definen un número «m» entre 0 y 7 determinan cuánto se cambia la frecuencia.
Primero el cálculo del período que es más fácil:
período = n / 128 segundos
Y el cambio de frecuencia se hace con esta fórmula:
frecuenciaX = frecuenciaX ± (frecuenciaX / 2^m)
O sea, se le suma o resta una diferencia que es el valor actual de frecuenciaX dividido por 2 «m» veces. Entonces, si «m» aumenta, el cambio de frecuencia es mas lento. Un ejemplo de cómo se vería esta onda es:
Se puede ver como los ciclos se hacen cada vez mas cortos
Al modificar frecuenciaX el valor tiene que quedar en el rango [0,2047], así que hay que chequear el nuevo valor antes de modificar la frecuencia final.
Si frecuenciaX es menor a 0, se pone en 0 y deja de modificarse, pero el canal sigue sonando. Si la frecuencia es mayor a 2047 el canal se deshabilita. Se puede verificar este comportamiento con sound_demo.gb configurando el canal para que ignore la duración y no tenga Envelope Sweep, pero configurando el Frequency Sweep.
Cuando la dirección «Swp Mode» es 0 (aumenta) el sonido se tiene que cortar en cierto momento, pero cuando es 1 el sonido queda sonando en un tono muy grave.
Si «Swp Time» o «Swp Shifts» valen 0, la frecuencia no cambia.
Lo de FrequencyXShadow es para no pisar el valor original de FrequencyX, porque si el sonido se vuelve a iniciar tiene que empezar desde la frecuencia original.
Con todo esto están los canales 1 y 2 completos! Muchas canciones de muchos juegos deberían sonar bastante bien. Hay varias situaciones que se pueden dar en el hardware original que producen un resultado no esperado y eso afecta a algunos juegos, pero por ahora no las voy a implementar. Están documentadas como Obscure Behaviors.
Canal 3 – Wave
Al 3er canal se lo conoce como «Wave», porque en lugar de generar un pulso, se lo puedo configurar para usar cualquier forma de onda. «Cualquier» es una forma de decir, porque obviamente tiene ciertas limitaciones.
En cuanto a features se puede configurar, además de la forma de la onda, una duración, un volumen constante y una frecuencia constante. No tiene sweep de ningún tipo.
Duración
Conceptualmente es igual a los primeros dos canales, se configura un valor en cierta posición de memoria, el valor se usa en un cálculo que determina la duración final de la nota, y hay un bit de otro registro que sirve para usar o ignorar la duración configurada.
El registro para la duración es 0xFF1B y en este caso se usa el byte completo, así que el valor de «L» va de 0 a 255, y el cálculo es:
duración = (256 – L) / 256 segundos
El bit 6 de 0xFF1E determina si se usa o no la duración, y si se está usando el canal se deshabilita cuando pasa el tiempo.
Creo que no hace falta ver el código de esto, es igual al de los otros canales salvo por algunos valores.
Frecuencia
Se configura igual que los otros canales, escribiendo un valor entre 0 y 2047, dividido en 2 partes, los 8 bits mas bajos en la posición 0xFF1D y los 3 mas altos en los 3 bits mas bajos de 0xFF1E. La única diferencia es el cálculo que se hace para la frecuencia final:
frecuencia = 65536 / (2048 – frecuenciaX)
Es decir, entre 32Hz y 64KHz.
Volumen
En lugar de definir un valor de 0 a 15 como en los otros canales se define un valor de 0 a 3. Cada valor corresponde a cuanto se modifica el valor de la onda que se configuró:
0 -> Mute 1 -> No se modifica 2 -> Se divide a la mitad 3 -> Un 25% del valor original
Se usan sólo los bits 5 y 6 del registro 0xFF1C, el resto se ignora. Es decir:
channel3.Volume = ((NR32 & 0x60) >> 5);
Mas adelante voy a implementar GetChannel3Sample y voy a mostrar como usé el volumen.
Habilitar
Al igual que los primeros dos canales, el canal 3 empieza a sonar sólo si se escribe un 1 en el bit 7 del registro 0xFF1E, pero además hay otro bit de otro registro que determina si el canal suena o no, el bit 7 de 0xFF1A. Este otro bit SI se puede usar para frenar el canal escribiendo un 0.
Onda
Esta es la parte mas interesante del canal 3, se puede configurar cualquier forma de onda dentro de los límites del hardware.
La forma está definida por 32 valores que definen la amplitud de la onda en 32 puntos consecutivos. Estos 32 valores son valores de 4 bits y se definen en 16 bytes de memoria, desde 0xFF30 hasta 0xFF3F. Cada uno de estos bytes contiene 2 de los valores, y están todos en memoria en orden. Es decir, el byte de 0xFF30 contiene 2 valores, el primero en los 4 bits mas altos, el segundo en los 4 mas bajos; el byte de 0xFF31 contiene el 3er valor de la onda en los 4 bits mas altos, y el 4to en los 4 mas bajos, etc. Algo así:
Esta onda corresponde a los bytes: 0x01 0x23 0x45 0x67 0x89 0xAB 0xCD 0xEF 0xFE 0xDC 0xBA 0x98 0x76 0x54 0x32 0x10
Entonces lo que hay que hacer para generar las muestras del canal 3 es calcular según el tiempo que lleva sonando el canal que instante del ciclo corresponde, de ese valor obtener un byte, y de ese byte obtener el valor concreto. Después se lo divide (o no) por el volumen, y ese es el valor final.
WaveRAM es un array de 16 «u8″s, que se actualiza en MMU::Write cuando se escribe en el rango [0xFF30,0xFF3F].
sound_debug.gb tiene una pantalla para probar el canal 3, pero hay dos problemas: por alguna razón no me funciona, pero además no tiene forma de ver o modificar la onda que se está usando, así que no es tán útil. No encontré ningún programa que muestres la onda configurada, en el próximo post voy a hablar de una tool que hice para debuggear sonido, pero mientras tanto una alternativa es capturar el audio de alguna parte en la que suene el canal 3 con un programa como Audacity y ver la onda ahí.
Canal 4 – Ruido
Este canal usa un generador pseudo aleatorio para producir una onda con valores que no sigan un patrón y así generar ruido. En realidad la onda sigue un patrón, porque el generador tiene límites y a partir de cierto punto la secuencia generada empieza a repetirse, pero la forma del ciclo da un resultado que se parece lo suficiente a ruido blanco.
Todavía no lo tengo funcionando del todo bien, pero quiero aprovechar este post para explicar lo que entendí hasta ahora.
Mas allá de la forma de la onda que es pseudo aleatoria, el canal tiene Envelope Sweep como los primeros 2 canales y una duración opcional como todos los demás. La configuración de la duración, el envelope y las formas de habilitar el canal son iguales a las del canal 1 y 2, así que no voy a repetirlo.
LFSR
Hay distintas formas de generar números pseudo aleatorios en una PC, una es usando un «Linear Feedback Shift Register» (o «LFSR») con «XOR», y el GameBoy usa esa técnica. No conozco mucho sobre esto ni la teoría que lo justifica, pero abajo dejo el link a Wikipedia para quien lo quiera investigar. Voy a dar una explicación mas práctica que es lo que mas me interesa para implementarlo en el emulador.
Partiendo del nombre, lo primero que hay que tener es un registro de cierto tamaño, que en este caso va a ser un «u16» (16 bits), aunque se usan solo 15.
Segundo, la parte de «shift» significa que para producir el output se hace un shift de los bits del registro. El que era el bit 0 antes del shift pasa a ser el output, y se agrega un bit nuevo en la posición 14. El valor de este bit es un XOR entre el bit que salió del regitro y el nuevo valor del bit 0 después del shift. Por esto de que el bit que sale se usa para el valor del bit nuevo es la parte de «Feedback».
El GameBoy tiene una particularidad, se puede configurar el LFSR para que trabaje con un registro de 7 bits en lugar de 15, y en ese caso el resultado del XOR pisa el valor del bit 6 además del bit 14. El resultado de usar un registro mas chico o mas grande es que tan «ruido» es el ruido, mientras mas chico es el registro, mas se parece el ruido a una nota, porque la secuencia de valores se empieza a repetir mucho antes. Para usar 7 bits en lugar de 15 hay que escribir un 1 en el bit 3 del byte de 0xFF22.
Frecuencia
Este canal no tiene una onda, así que no se le puede definir una frecuencia, pero el hardware usa un clock interno para hacer el shift del LFSR y esa frecuencia si es configurable. El cálculo es medio raro porque se definen 2 valores diferentes que se usan en el mismo cálculo, un divisor «r» entre 0 y 7 y un shift «s» entre 0 y 15. El divisor son los primeros 3 bits de 0xFF22 y el shift son los últimos 4. El cálculo con esos valores es:
frecuencia = 524288 / (r * 2^(s+1)) Hz
Cuando r vale 0, en el cálculo se usa 0.5 en vez de 0.
Para los valores mas altos este cálculo da frecuencias muy bajas y no se escucha nada relamente. Ej, para r = 7 y s = 15, la cuenta da 2.28 Hz. Esto es algo que tengo que seguir investigando porque en BGB y Gambatte se escucha ruido para mas valores que en mi emulador, así que seguro algo me está faltando.
Implementación
La salida del LFSR es un bit, 0 o 1, y se está actualizando constantemente a la frecuencia configurada. Al tomar una muestra del canal 4 hay que tomar el último valor que generó el LFSR, si es 1 devolver el valor del volumen actual, si es 0 el valor del volumen pero negativo, así que GetChannel4Sample es muy simple:
«lfsrOutput» es el último valor que generó el LFSR, que es justamente la parte interseante de este canal. Primero, así traduzco los valores de los registros a una frecuencia y, mas importante, un período:
Eso quiere decir que cada «channel4.Period» segundos tengo que simular un paso del LFSR, así que al actualizar el componente de Audio ejecuto tantos pasos de LFSR como pueda en el tiemp que estoy simulando:
Un detalle que implementé pero no mencioné antes porque no se que tan importante es, es que el output del LFSR se invierte, así que en realidad si el bit 0 era un 0, el output es un 1.
El problema con esto es que el LFSR genera un 0 o un 1, y al leer una muestra el valor va a ser siempre 0 o 1, y a baja frecuencia termino teniendo «ondas» que son casi una línea recta, por eso deja de sonar cuando empiezo a subir los valores de los registros. Creo que la solución a esto es interpolar los valores, para de alguna forma devolver en una sola muestra información de mas de un bit, pero todavía no lo pude probar bien. Seguramente mire como lo resuelven otros emuladores en lugar de probar a ciegas, hay muy poca documentación sobre esto. Por ahora mi ruido se ve así:
Conclusión
Bueno, acá termina otro post largo, pero no termina el tema del sonido. Queda la configuración global de volumen y del stereo, pero también muchos detalles para implementar y casos especiales que hay que considerar, y además quiero contar sobre la tool que hice para debuggear las muestras generadas. Probablemente siga con Optimizaciónes y GameBoy Color antes de retomar con la (espero) última parte sobre audio.
Sobre los canales en si, son medio complejos de entender porque la información general está muy compactada, pero espero que se haya visto que la implementación en si no es tan compleja una vez que la arquitectura del emulador está preparada para emular sonido de forma correcta.
En el post anterior expliqué como emulé la funcionalidad básica para reproducir sonido en el emulador.
El oído es muy sensible al sonido y notamos mucho cualquier inconsistencia. Cualquier silencio o salto durante la reproducción lo podemos escuchar aunque dura solo unos milisegundos, por eso es muy importante la sincronización.
Hasta ahora no me preocupaba tanto el tema porque si el juego se ve mas rápido o mas lento igual podemos acostumbrarnos, pero con el sonido es RE importante el tema.
Además es importante la sincronización porque si se generan los sonidos a diferente frecuencia estamos cambiando los tonos. Todos conocemos el efecto de acelerar un video y el audio, que hace que las voces se vuelvan mas agudas. Es posible corregir esto en ciertos contextos, pero para el emulador realmente no tiene sentido estirar o comprimir el sonido, hay que reproducirlo a la velocidad original.
Estado actual
Con lo que expliqué en el post anterior pueden ocurrir dos problemas:
El emulador anda lento lento y no puede generar un segundo de audio en un segundo real.
O anda rápido y genera un segundo de audio antes de reproducir completo el segundo anterior.
Si el problema es el primero, antes de sincronizar cosas hay que optimizar. Si el emulador no tiene la velocidad suficiente no hay forma de sincronizarlo con ninguna técnica. Una vez que se arregle el problema 1 seguramente se pase a tener el 2do, y ahí recién se puede aplicar lo que voy a contar.
En mi caso yo estaba en el caso 1 compilando en modo debug y en el caso 2 compilando en modo release, pero hice algunas optimizaciones para estar en el caso 2 en los dos modos en mi PC. En otro post voy a hablar sobre optimizaciones del emulador y como medir la velocidad a la que funciona.
Otro dato importante del post anterior es que para arreglar el problema del delay de 1 segundo por reproducir el audio cada 44100 muestras generadas hice que haya un delay de 1 solo frame, reproduciendo el audio cada 735 muestras, equivalente a 1/60 segundos de muestras. Acá subí 4 videos mostrando como suenan los 4 problemas.
60 cuadros por segundo
En gaming en general es importante y deseable que los juegos funcionen a una cantidad de cuadros por segundo constantes. El motivo no es el mismo que con el emulador, porque el juego no debería funcionar mas rápido o mas lento sólo porque se dibujan mas o menos cuadros por segundo.
Mas allá de que nuestro cerebro no es tan sensible al video como al audio, también notamos bastante cuando el video tiene un frame rate variable, y eso es una de las cosas que se intenta evitar en gaming.
Cómo conozco las técnicas que se usan en gaming mis primeros intentos para arreglar el emulador fueron por este lado y NO funcionaron, pero quiero contarlo igual para que sepan cuales son y no lo intenten en sus casas.
VSync
Cuando hablé de la GPU hablé de esto, y es algo que es tan cierto hoy como en cualquier generación de consolas. Las pantallas no dibujan toda la imagen al mismo tiempo, dibujan pixel por pixel de izquierda a derecha, línea por línea de arriba hacia abajo, y cuando terminan con toda la pantalla generan una señal en el hardware.
La pantalla del GameBoy funciona a 60Hz, y los monitores actuales también, entonces una opción es sincronizar el emulador usando la señal de VSync.
SFML tiene una opción para que una ventana use VSync, así que durante un tiempo la usé y parecía funcionar, pero en algún momento dejó de funcionar y no se realmente por qué. Mas allá de eso, cuando funcionó la sincronización el sonido se escuchaba raro, así que algo mas pasaba.
Sleep
Cuando una aplicación sincroniza algo con VSync, lo que suele hacer es realizar cierta cantidad de trabajo (que tarda menos de 1/60 segundos en completarse) y después espera sin hacer nada hasta recibir la señal del monitor.
Se puede simular algo parecido pero en lugar de esperar hasta recibir la señal, se espera a que pase el tiempo restante del frame.
O sea:
Al empezar un frame se guarda el tiempo actual
Al terminar el trabajo de un frame se toma el tiempo actual
Se restan los dos tiempo para obtener el tiempo que se estuvo trabajando
Se le resta ese valor a 1/60 segundos, para obtener el tiempo que falta para que termine un frame
Se frena (duerme) el proceso durante ese tiempo
Si esto se hace bien, cada frame tarda 1/60 segundos y la sincronización es equivalente al VSync.
En C++11 en la biblioteca standard hay 2 headers con funciones útiles para esto: thread y chrono. En partícular, thread tiene la función sleep_for que frena el proceso actual durante cierto tiempo, y chrono tiene la funcion now para saber el tiempo actual y el tipo de dato duration para representar intervalos de tiempo.
El uso de chrono::duration es medio raro y creo que no vale la pena explicarlo, pero tiene la precisión que se necesita para estas cosas.
Mi implementación de esto fue algo así:
using frame_duration = std::chrono::duration<float, std::ratio<1,1>>;
...
int main() {
...
frame_duration frameTime(1/60.0f);
auto frameStartTime = chrono::system_clock::now();
while (gameWindow.IsOpen()) {
...
bool frameFinished = gpu.Step(lastOpCycles * 4);
if (audio->Step(lastOpCycles)) {
// lo mismo del post anterior
}
if (frameFinished) {
// actualizo las ventanas
// proceso eventos con SFML
auto currentTime = chrono::system_clock::now();
auto diff = currentTime - frameStartTime;
if (diff < frameTime) {
std::this_thread::sleep_for(frameTime - diff);
// la línea que sigue se ejecuta después
// de "frameTime - diff" segundos
frameStartTime = chrono::system_clock::now();
}
}
}
}
Ya no tengo esta versión del código así que lo hice medio de memoria y puede tener errores.
Ese código es bastante común, pero de todas formas no me estaba funcionando del todo bien, y cuando funcionaba me generaba un sonido con mucho ruido, igual que cuando pude usar VSync, así que de nuevo era claro que había otro problema además de la sincronización.
Algo raro que notaba es que si reproducía un segundo de audio, el segundo sonaba bien. Pero si reproducía 60 sonidos de 1/60 segundos tenía mucho ruido. Estuve muchas horas tratando de saber que pasaba, haciendo capturas de audio, comparando archivos .ogg, probando cosas medio aleatorias como reproducir de a 2 frames en lugar de 1. Pensé que era un problema de en donde estaba reproduciendo los sonidos así que moví la llamada a Play a varios lugares, pero nada funcionaba bien.
Solución
Después de muchas pruebas en algún descansó me decidí por probar algo que venía evitando porque no lo entendía del todo y pensaba que tenía que modificar mucho el emulador si lo hacía, pero ya no me quedaban ideas.
Hasta ahora mi solución era emular ciclos de la CPU uno después de otro, y parar cada vez que tenía información suficiente para reproducir un frame de audio. O sea, la velocidad de emulación de la CPU determina la velocidad de reproducción del audio y como expliqué esto es dificil de sincronizar.
Pero la sincronización de los componentes puede darse en la otra dirección. Puedo poner a reproducir el audio, que va a estar sincronizado con el hardware de la PC, y cada vez que el audio necesita muestras emulo los ciclos necesarios de CPU.
Esto no es muy intuitivo y hay que saber que es posible reproducir audio sin tener la información completa, algo que normalmente no se enseña porque no tiene tanta utilidad. Yo lo había leído en uno de los blogs que mencioné en el post anterior, pero realmente lo empecé a entender con el video de OneLoneCoder.
El flujo del programa cambia y ya no es tan fácil de seguir, así que voy a hacer unos diagramas.
Solución actual que no funciona bien:
Solución nueva:
La ventaja de esta solución es que de ese diagrama hay dos cosas que pasan automáticamente: la pregunta de si hay o no suficientes muestras de audio, y la reproducción.
El hardware de sonido reproduce las muestras de a grupos, cuando no tiene muestras (porque recién empieza o porque ya reprodujo las que tenía) ejecuta una función para obtener nuevas. En esta función hay que avanzar el emulador lo mas rápido posible hasta tener un grupo de muestras y como resultado de la función simplemente actualizamos unos valores internos con las muestras nuevas. Cuando esa función termina el hardware de audio reproduce las muestras hasta terminarlas, y durante este tiempo el emulador no avanza.
De esa forma se logra la sincronización, y es mucho mas precisa que usar sleep_for.
SFML
Después de entender como funciona esta solución en la teoría, hay que ver como llevarla a la práctica, y no estaba seguro de como hacer algo así con SFML.
Acá es cuando me di cuenta de otro problema que explicaba por qué, incluso cuando logré sincronizar bien las cosas con sleep_for, el audio se escuchaba con mucho ruido.
Yo estaba usando las clases Sound y SoundBuffer, pero mirando la documentación no había nada para hacer lo que necesitaba con esta nueva solución.
SFML tiene otra clase para reproducir audio, Music. Esta clase es ideal para reproducir audios largos porque a diferencia de Sound que necesita un buffer con todas las muestras, Music puede obtenerlas en el momento, streameando. Esto se usa para reproducir archivos de música grandes (decenas o cientos de MB) sin cargar todo el archivo completo en memoria. La clase Music puede cargar parte de un archivo en un buffer interno y lo actualiza con mas muestras constantemente.
El problema con usar Sound para el emulador es que aunque estoy emulando y reproduciendo sonidos cortos (milisegundos), el resultado que necesito no es un sonido corto. No se pueden sincronizar reproducciones de Sound para tener un sonido constante, no iba a poder arreglarlo nunca así. Por eso si reproducía de a un segundo se escuchaba bien pero 60 sonidos de 1/60 con las mismas muestras se escuchaban mal.
El sonido del emulador es mas parecido en la práctica a lo que hace Music, es una sola «canción» que empieza cuando abro el emulador y termina cuando lo cierro, el flujo de muestras es continuo aunque el juego esté en silencio.
Lo malo de Music es que, igual que Sound, está pensaba para reproducir sonido desde archivos, pero buscando un poco mas encontré finalmente la solución: SoundStream.
SoundStream es una clase abstracta pensada para crear clases derivadas que tomen las muestras de cualquier lado. En mi caso tengo que hacer mi propia clase derivada de SoundStream que genere las muestras emulando ciclos de la CPU. La clase Music es una especialización de SoundStream hecha para funcionar con archivos.
Implementación
Para hacer todo esto primero tuve que modificar main para que todo lo que relacionado con emular la consola (instrucciones de CPU, update de GPU, Audio, etc) se ejecute dentro de una única función. Actualmente tengo un montón de variables globales y una función que los usa y le paso la función a mi implementación de SoundStream, pero mi plan es encapsular todo esto en un objeto que represente la consola completa.
Mas allá de los detalles de mi clase derivada de SoundStream, la función realmente importante quedó así:
bool CustomAudioStream::onGetData(Chunk& data) {
// 735 muestras de audio son 1 frame
while (sampleIndex < 735)
mainLoopFunction();
data.samples = samples;
data.sampleCount = 735;
sampleIndex = 0;
return true;
}
SFML se encarga de llamar a esa función cuando ya no tiene muestras y de reproducir el sonido según los samples de «data».
main.cpp quedó así:
void MainLoop() {
// ejecutar instrucción de CPU
// actualizar GPU y setear frameFinished
// actualizar timer, dma, etc
// actualizar audio y el buffer de muestras
}
int main() {
...
CustomAudioStream audioStream(MainLoop, sampleIndex, samples);
// esto inicia el thread de audio
audioStream.play();
while (gameWindow.IsOpen()) {
// acá antes se hacía lo que ahora está en MainLoop
if (frameFinished) {
// actualizo las ventanas
sf::Event event;
while (gameWindow.PollEvent(event)) {
// proceso eventos de SFML
}
}
}
audioStream.stop();
return 0;
}
Con estos cambios se arreglaron los problemas!
Después de muchas pruebas e intentos fallidos el fin de semana, pude sincronizar perfecto el sonido! Siguen siendo sólo 2 canales y faltan muchas cosas, pero ya no hay cortes ni delays (bueno, 1 frame de delay). pic.twitter.com/nGVB5ec4Gb
El sonido todavía se escucha raro, pero ya no es por problemas de sincronización ni de reproducción, es porque me falta emular mucha funcionalidad y mas canales.
Extra
Al hacer todo esto el emulador funciona mucho mejor, pero está bueno seguir teniendo la opción de emular lo mas rápido posible al menos por 2 motivos:
Si la PC es buena, se tiene automáticamente una opción de fastforward.
Para optimizar el emulador hay que saber a cuandos FPS funciona, pero sincronizar con el audio lo limita a 60.
Hacer esto es relativamente trivial, es cuestión de llamar a MainLoop dentro del while de main o dentro de CustomAudioStream de acuerdo a un booleano. Cuando se aprieta una tecla se pausa el audio y se empieza a llamar a MainLoop en main.cpp, cuando se suelta la tecla se vuelva a reproducir el audio y se deja de llamar a MainLoop en main.cpp.
int main() {
...
bool syncWithAudio = true;
CustomSoundStream audioStream;
if (syncWithAudio)
audioStream.play();
while (gameWindow->IsOpen()) {
if (!syncWithAudio)
MainLoop();
if (frameFinished) {
// actualizar ventanas
sf::Event event;
while (gameWindow.PollEvent(event)) {
// proceso eventos de SFML
if (event.type == sf::Event::KeyPressed
&& event.key.code == sf::Keyboard::F) {
syncWithAudio = false;
audioStream.stop();
}
if (event.type == sf::Event::KeyReleased
&& event.key.code == sf::Keyboard::F) {
syncWithAudio = true;
audioStream.play();
}
}
}
}
if (syncWithAudio)
audioStream.stop();
}
Conclusión
Este post es mas corto de lo normal (bien!) pero me pareció importante dedicarle un tiempo para hablar en detalle de esto. El resultado de sincronizar la emulación a la reproducción de audio es muy bueno y requiere mucho menos trabajo del que pensaba. Todavía faltan emular muchas cosas para que se escuche como el audio original, pero tener el juego funcionando a la velocidad normal es re importante.
Después de mucho tiempo sin poder avanzar con el emulador por fin tengo tiempo y decidí de una vez por todas emular el sonido. Es algo que venía pasando siempre para mas adelante por 3 motivos:
Quería tener la mayoría de los features implementados para tener mas confianza de que errores al emular el sonido no sean producto de otras fallas
Siempre hay mas cosas para agregar (ej: soportar juegos de game boy color, agregar features del emulador, optimizar y emprolijar el código)
Y lo mas importante, NO TENÍA IDEA sobre el tema. Siento que es el tema mas complejo de entender de todo el emulador
Antes de empezar a hacer cosas me leí varias veces la documentación (que es muy poca y pensada para quien entiende del tema), busqué información en blogs e incluso busqué documentación de otras consolas que tienen hardware y funcionamiento parecido. Para agregarle misterio y complejidad al tema, la mayoría de la gente escribiendo sobre desarrollo de emuladores NO implementan el sonido, y los pocos que lo hacen no hablan mucho del tema.
Lo que me hizo entender como empezar con esto es principalmente este video de OneLoneCoder. Si les interesa el contenido de este blog y entienden inglés les va a interesar esa serie completa, y si se enganchan miren mas videos porque toca temas RE interesantes y avanzados y es de lo más didáctico.
Al final voy a poner muchos links con información sobre el tema, pero voy a tratar de explicar en detalle todo lo que pueda en estos posts, asumiendo que quien lee esto, al igual que yo, no sabe por donde empezar y no sabe que significan la mayoría de los términos. En la facultad aprendí algunas cosas sobre el sonido desde el punto de vista físico y matemático, y conozco algunas cosas de sonido digital, pero no sabía como usar ese conocimiento en el emulador.
Lo que sigue probablemente tenga errores conceptuales porque no soy ni de cerca experto en el tema, así que cualquier corrección es bienvenida.
Onda
Forma
Los sonidos son vibraciones del aire que nuestro oido y cerebro interpretan como notas diferentes. Lo mas simple es empezar hablando de un sonido que sea un tono constante, y para esto el aire tiene que vibrar como una onda sinusoidal:
Las ondas tienen 2 componentes principales: La frecuencia, que se expresa en «Hz» (hertz) y la amplitud que en principio no tiene unidad de medida. La frecuencia es cuantas veces en 1 segundo se repite un ciclo completo.
Diferentes frecuencias producen diferentes tonos, y diferentes amplitudes producen diferentes volumenes. Las frecuencias bajas dan la sensación de que el volumen también bajó, pero lo importante es que para un mismo tono el volumen se cambia modificando la amplitud. En esta página se puede ver como a mayor frecuencia el tono es mas alto.
También es importante el concepto del período de una onda, que es el tiempo que tarda en completarse un ciclo. Es decir, el período es 1/frecuencia. No tiene mucha ciencia y probablemente no dije nada nuevo porque es un concepto muy usado, pero lo aclaro porque lo voy a usar.
Tipos
Las ondas sinusoidales generan tonos puntuales (440Hz producen un «LA», 493.88Hz un «SI»), pero no son las únicas formas de ondas posibles. En principio una onda puede tener cualquier forma que se repita con cierta frecuencia y va a generar sonido, pero si no es sinusoidal genera una combinación de tonos. Es complejo el tema y creo que no tiene sentido que intente explicarlo, pero la idea es que cualquier onda de cualquier forma se puede generar sumando ondas sinusoidales de diferentes frecuencias y por eso se escuchan diferentes tonos al mismo tiempo. Si les interesa leer sobre esto al final dejo un link sobre Series de Fourier. Lo importante es que existen otras formas de ondas y que algunas son bastante usadas entonces tienen nombre propio: Pulso (o Rectangular), Triangular y Sierra.
En este link se puede escuchar como cambia el sonido para una misma frecuencia usando las distintas formas de onda: Y en este link hay una visualización y escuchación (?) de como se relacionan una onda cuadrada con la sinusoidal usando la serie de Fourier (a la izquierda hay links para Triangular y Sierra).
Duty Cycle
Una característica mas que tienen las ondas pulso es el «Duty Cycle» o «Cíclo de trabajo», aunque creo que todo el mundo le dice Duty Cycle también en español así que lo voy a llamar así todo el tiempo. Define que porcentaje de un ciclo de la onda el pulso está arriba y, por lo tanto, que porcentaje está abajo. Se define como un porcentaje o una proporción, y si la onda tiene un duty cycle del 50% también se la conoce como onda cuadrada.
En Wikipedia hay un gif mostrando como cambia la forma de la onda con distintos Duty Cycles.
No pude encontrar ningúna para probar y escuchar el efecto de distintos duty cycles, así que dejo un link a un video pero CUIDADO CON EL VOLUMEN, VAN A ESCUCHAR TONOS MUY FUERTES Y MUY AGUDOS: https://youtu.be/72dI7dB3ZvQ?t=864
Cambiar el Duty Cycle es equivalente a cambiar las frecuencias de las ondas sinusoidales de la serie de Fourier, así que le resultado es que se escuchan diferentes tonos, aunque la frecuencia de la onda pulso no cambie.
GameBoy
Canales
El hardware de gameboy tiene 4 canales de audio así que puede generar 4 sonidos diferentes al mismo tiempo, que después se mezclan para producir un sonido final. El concepto es análogo a tener varias pistas para distintos instrumentos en una canción, que se reproducen al mismo tiempo y se combinan en un sonido final.
Cada canal tiene sus características y permiten generar sonidos diferentes, pero por ahora sólo voy a hablar de los primeros 2 porque son casi idénticos. El canal 2 tiene un feature menos, pero no voy a cubrir esa diferencia todavía, así que implementar el 2 es literalmente copiar lo que se haga para el 1.
Toda la explicación anterior de los tipos de onda fue porque en el GameBoy los primeros 2 canales generan ondas pulso, así que el juego va a estar configurando la frecuencia, el volumen y el duty cycle de cada canal. Cambiando la configuración de cada canal en intervalos muy cortos de tiempo se genera música.
Al igual que con los otros componentes, hay un componente de hardware que genera el sonido de los 4 canales, los combina y manda el resultado a los parlantes, y la forma de configurar el componente es escribiendo valores en posiciones de memoria específicas. Son un montón porque cada canal usa 4 o 5 registros, y además hay otros registros generales que afectan como se mezclan entre si y a que parlante va cada canal, porque soporta sonido stereo.
Duty cycle
Antes di una explicación mas teórica de que significa esto, pero ahora veamos cómo se define en la consola.
En el GameBoy no se puede definir un porcentaje explícitamente, el hardware solo está preparado para trabajar con 4 valores de duty cycle: 12.5%, 25%, 50% y 75%. Como son 4 opciones, los juegos en relaidad escriben un número de 0 a 3 en binario en los bits 6 y 7 de un byte de memoria específico (0xFF11 para el canal 1 y 0xFF16 para el 2), y cada valor corresponde a un porcentaje.
En código es algo así:
switch (ValorDe0xFF11 >> 6) {
case 0x00: channel1.DutyCycle = 0.125f; break;
case 0x01: channel1.DutyCycle = 0.25f; break;
case 0x02: channel1.DutyCycle = 0.5f; break;
case 0x03: channel1.DutyCycle = 0.75f; break;
}
Frecuencia
Las frecuencias dije que se definen en Hz (o KHz o MHz), pero por como está diseñado el hardware no se definen así en el GameBoy.
La consola soporta frecuencias dentro de un rango grande, pero lo que el juego escribe en la memoria es un valor que se usa en un cálculo para obtener la frecuencia final. Lo que permite esto es configurar con valores chicos (entre 0 y 2047) frecuencias dentro de un rango grande (64Hz – 131072Hz).
El cálculo es:
frecuencia = 131072 / (2048 – x) Hz, siendo «x» el valor que define el juego
A partir de ahora voy a hablar de frecuencia para el valor final en Hz, y «frecuenciaX» para el valor «x» que define el juego, aunque no sea realmente una frecuencia.
Pero 2047 no se puede representar con 1 byte que es la unidad de memoria que maneja la consola, así que el valor de la frecuencia se define en mas de un paso. Para representar el 2047 se necesitan 11 bits, los juegos tienen que escribir los 8 bits mas bajos de la frecuenciaX en una posición de memoria (0xFF13 para el canal 1, 0xFF18 para el 2) y los 3 bits mas altos en los primeros 3 bits de la posición siguiente (0xFF14 para el canal 1, 0xFF19 para el 2)
En código es algo así:
channel1.FrequencyX = 0; // FrequencyX es un entero de 16 bits
channel1.FrequencyX = ValorDe0xFF13; // ValorDe0xFF13 es un entero de 8 bits
channel1.FrequencyX = ((ValorDe0xFF14 & 0x07) << 8) + channel1.FrequencyX;
channel1.Frequency = 131072 / (2048 - channel1.FrequencyX);
channel1.Period = 1 / channel1.Frequency;
Estoy copiando código pero modificándolo un poco para que sea mas claro, en el emulador tuve que hacer algunas cosas diferentes por cuestiones que no vienen al caso, pero si comparan van a notar que no es exctamente igual.
Volumen
Además de un volumen general, cada canal puede tener un volumen propio. Pueden configurar un volumen variable también, pero por el momento lo voy a saltear para no hacer tan largo este post.
El volumen se define en los 4 bits mas altos de un registro (0xFF12 para el canal 1, 0xFF17 para el 2):
channel1.Volume = ValorDe0xFF12 >> 4;
Es decir que el volumen puede valer entre 0 y 15, sin unidad.
Habilitar
Todos los canales suenan mientras estén habilitados, así que el juego tiene que habilitarlos escribiendo un «1» en el bit 7 de posiciones puntuales de memoria (0xFF14 para el canal 1, 0xFF24 para el 2). En general lo que hacen los juegos es tener el canal deshabilitado, setean la frecuencia, duty cycle y volumen, y después escriben ese 1 para habilitar el canal y que empiece a sonar. En otro post voy a hablar sobre como se frena (no es poniendo un «0» en el mismo bit), pero por ahora quiero implementar lo mínimo.
Emulación
Los canales son mas compejos que lo que conté hasta ahora, pero con lo que expliqué ya se debería poder escuchar algo.
Hasta ahora todo lo que hice fue reinterpretar los valores que el juego escribe en la memoria para tener la información lista de una forma mas útil. Lo que sigue es emular el hardware de sonido, ¿pero que significa esto? Esta es una de las cosas que mas me frenaban para arrancar con esto, no me quedaba claro que hace el juego, que hace la consola (o sea, el emulador), y como se traduce eso en sonido que la PC pueda reproducir.
El juego
Al igual que con los sprites que no están guardados en archivos, tampoco los sonidos están guardados en archivos. El juego simplemente copia valores de la memoria ROM a ciertos lugares de la memoria del sistema en momentos bien sincronizados y con esos valores cambian los tonos que se escuchan. La información de cuando y como modificar los registros tampoco está en un formato compatible con hardware y software actual.
La PC Actualmente el hardware de sonido funciona de una forma mucho mas estandarizada, así que necesito sonidos en un formato que SFML pueda interpretar. Hay varias clases para manejar esto de las que voy a hablar mas adelante.
La consola El hardware de audio entonces tiene que simular el paso del tiempo, según las frecuencias y duty cycles ver a que punto de cada onda corresponde ese instante, y combinarlos para obtiener un volumen actual. NO genera tonos, los tonos son lo que interpreta nuestro oido según que tan rápido cambia el resultado.
El código que voy a escribir no replica exactamente el hardware original, por ahora prefiero hacer algo que simplemente genere un resultado equivalente para poder entender mejor el tema, y capaz en el futuro voy a intentar una solución que simule todo el funcionamiento interno del hardware.
Muestras
El sonido digital (cualquier sonido generado o reproducido en una computadora) es una secuencia de valores discretos. Es análogo a como un video en realidad son 60 imágenes distintas y no los infinitos estados intermedios. De la misma forma que cada imagen en un video es un «cuadro», para los sonidos cada valor de la secuencia es una «muestras» o «sample». Son valores enteros, y en el caso de SFML y supongo que en general, son enteros de 16 bits con signo. O sea, un valor entre −32,768 y 32,767.
De la misma forma que al aumentar los cuadros por segundo de un video la calidad del movimiento aumenta, al aumentar las muestras por segundo de un sonido la calidad también aumenta. Esta cantidad de muestras por segundo se llama frecuencia de muestreo (o «sample rate»), y se expresa en Hz.
Distintas tecnologías de audio digital usan distintos valores de sample rate y por eso tienen distinta calidad de sonido. Por ejemplo, los CDs usan 44100 Hz y los DVDs 48000 Hz. El hardware de cualquier PC actual permite reproducir audio con distintos sample rates, así que en principio hay que elegir un valor. Muchos emuladores permiten configurarlo y así el usuario puede ganar velocidad aunque empeore la calidad de sonido, pero por ahora yo voy a trabajar con un valor fijo de 44100 muestras por segundo.
Ese número es otro de los motivos por los que venía evitando ponerme a trabajar en esto: un segundo de audio son 44100 muestras de sonido, ¿cómo se que estoy generándolos bien? ¿cómo lo debugueo? ¿cómo se si un sample que vale «5822» está bien? ¿puedo saber cual es el valor siguiente? Con la parte visual es mas simple porque son sólo 60 cuadros en un segundo y se puede pausar y ver que la imagen actual tiene sentido, pero con el sonido no se puede «pausar» de la misma forma. Encima tampoco es que puedo elegir otro número mucho mas bajo, 8000 Hz es lo mínimo escuchable para un humano: https://github.com/audiojs/sample-rate
En un post futuro voy a hablar de una tool que me hice para entender todo esto un poco mas.
Retomando… decidí que voy a necesitar generar 44100 muestras de sonido por segundo, es decir, 1 muestra cada 0.00002267 segundos. Pero no puedo simplemente ejecutar el programa y cada 0.00002267 segundos reales generar una muestra, tengo que sincronizar esto con la velocidad del emulador de alguna manera.
Sincronización
La CPU tiene un clock de 1.048576 MHz, o sea que por cada clock de CPU emulado, pasaron 1 / (1.048576 * 1000000) segundos emulados, o 0.00000095374 segundos.
Tengo que generar 1 muestra cada 0.00002267 segundos emulados, así que cada vez que emulo una instrucción voy acumulando en un contador el tiempo equivalente, y cada vez que ese valor supere los 0.00002267 segundos calculo una muestra y vuelvo el acumulador para atrás. Calculando la proporción debería estar calculando una muestra mas o menos cada 23.7 ciclos de CPU.
De esta forma al emular 1 segundo de tiempo (1048500 ciclos) generé 44100 muestras, o 1 segundo de audio.
En el loop de main cada vez que se ejecuta una función tengo el dato de cuantos ciclos se emularon, así que tengo que agregar ahí el update del componente Audio. El componente va a actualizarse y si pasó suficiente tiempo va a generar una muestra y devolver true, si no false.
int main() {
...
while (gameWindow.IsOpen()) {
...
u8 lastOpCycles = cpu.lastOpCycles;
...
if (audio.Step(lastOpCycles)) {
// acá tengo disponible una nueva muestra en audio.sample
}
...
}
}
En un rato voy a hablar de GetSample y de qué hacer con la muestra generada, pero todavía faltan entender un par de cosas. Lo importante es que con esa estructura ya tengo una forma de sincronizar la CPU y el audio, y de generar un sample cada 0.00002267 emulados.
Es importante tener en cuenta que para que todo esto se escuche bien el emulador tiene que funcionar a la misma velocidad que el hardware real, y una forma fácil de medir esto es asegurarse que funcione a 60 cuadros por segundo. Idealmente tiene que andar a mayor velocidad y con algún mecanismo limitarlo. Para lograr esto tuve que hacer varias cosas, pero va a ser un tema de otro post.
La consecuencia de que funcione a menos de 60FPS es que tarda mas de 1 segundo en generar 44100 muestras, por lo que empiezan a notarse microcortes en el audio. La consecuencia de que funcione a mas de 60FPS es que el sonido se genera más rápido que lo que se reproduce y se termina pisando a si mismo.
Sonido en SFML
SFML tiene una clase Sound que está pensada para reproducir sonidos cortos (cuando se aprieta un botón, cuando se dispara un arma, el ruido de un animal, etc). Esta clase puede reproducir un sonido desde un archivo, pero también puede reproducir muestras de un SoundBuffer (otra clase de SFML). Mientras hacía esto el emulador ya andaba a mas de 60 FPS compilado en modo release, pero todavía no tenía ningún mecanismo para limitar la velocidad a 60 FPS constantes. Lo que se me ocurrió fue que podía emular hasta tener 44100 muestras (1 segundo de audio) y reproducirlo. Para esto no importa mucho si el emulador anda mas rápido o mas lento de lo esperado, puede tardar todo el tiempo necesario para generar un segundo completo y ese segundo se reproduce bien. El siguiente segundo de audio no va a sonar justo después del anterior, pero al menos en 1 segundo de audio se puede comprobar si las cosas suenan como deberían.
Entonces lo que hice fue algo así:
if (audio.Step(lastOpCycles)) {
// samples es un array de sf::Int16 de 44100 elementos
samples[sampleIndex] = audio.sample;
sampleIndex++;
if (sampleIndex == 44100) {
// actualizar el SoundBuffer
// actualizar la instancia de Sound con el SoundBuffer actualizado
// reproducir el sonido
}
}
Con SFML además de poder usar un SoundBuffer para la instancia de Sound que se quiere reproducir, también se puede guardar en un archivo de audio normal (ej, un .ogg), y así es posible guardar el segundo emulado y reproducirlo y analizarlo por fuera del emulador con un programa como Audacity. Tener un segundo completo de audio para escuchar es mucho mas útil que tener un par de muestras, porque podés escuchar algo reconocible. No es suficiente para confirmar que se está generando 100% bien, pero da una buena idea. No voy a mostrar esto porque no aporta tanto y no tiene mucha ciencia (es literalmente una función), pero me sirvió así que lo quería contar.
Después de implementar esto (y GetSample, ya voy a llegar a eso) pude escuchar por primera vez algo! Y algo RECONOCIBLE:
Parece una pavada, pero de verdad no lo podía creer cuando sonó bien. Mi primer intento no había sido muy bueno, ojo con el volumen que tiene sonidos muy agudos:
Todo un éxito el primer intento de emulación del audio… (cuidado con el volumen si tienen auriculares!) pic.twitter.com/hElCm0bg90
Mas allá de las ventajas de emular de a 1 segundo esto tiene un problema muy claro: un delay de 1 segundo en el sonido, porque estoy esperando todo un segundo de emulación para reproducirlo. Esto es relativamente fácil de arreglar: en lugar de generar todo un segundo de muestras genero un solo frame y lo reproduzco, y genero otro y lo reproduzco. De esta forma el audio solo tiene un delay de 1 frame que es imperceptible. Si el buffer tiene un solo frame ya no tiene mucho utilidad guardarlo como un .ogg, pero bueno, después de varias pruebas y ajustes emulando 1 segundo completo ya tenía un poco mas de confianza de que las cosas estaban funcionando mejor.
Pero hacer esto hizo mas notorios los problema del emulador no funcionando a 60 FPS constantes, si un frame es mas corto de lo esperado los frames de audio se pisan, y si es mas largo hay microcortes. Cuando emulaba de a 1 segundo esto se escuchaba cada tanto, pero ahora se escuchaba mal entre cada frame. Era muy feo de escuchar.
Por ahora no voy a hablar sobre como solucioné esto, perdí mucho tiempo tratando de solucionar problemas que no tenían solución porque estaba haciendo cosas mal pero quiero explicar los problemas y la forma correcta de arreglarlos en detalle en otro post.
GetSample
Ahora si, ¿Que significa generar una muestra del sonido? En primer lugar hay que generar una muestra para cada canal activo, después hay que combinarlas y por último hay que transformar ese numero en un número normal para muestras de audio.
Los dos pasos del final los hice muy simples por ahora: sumo las muestras de cada canal y multiplico el valor por otro número grande. Este número grande es medio aleatorio y es cuestión de probar hasta encontrar alguno que funcione bien, porque en realidad define el volumen así que no hay un valor correcto. Lo único que tengo que tener en cuenta es que no se pase del rango [−32,768, 32,767]:
Para generar la muestra de cada canal primero tienen que estar habilitados. Cada canal tiene su propio contador de tiempo que se reinicia cuando se habiltita, entonces de acuerdo a cuanto tiempo lleva habilitado se puede saber que lugar de la onda se está reproduciendo.
Por ejemplo, supongamos que se configuró el canal 1 con una frecuencia de 64Hz, un duty cycle de 25% y un volumen de 15, y se lo activa. 64 Hz es un período de 0.015625 segundos, de los cuales el 25% (0.00390625 segundos) la señal está en 1 y el resto (0.01171875 segundos) está en -1.
Las muestras se toman cada 0.00002267 segundos, así que durante un ciclo completo se generan 172 muestras valiendo 1 y 516 valiendo -1. Después empieza otro ciclo y se repiten mas o menos las mismas cantidades.
En algún momento el canal se deshabilita y siempre que esté deshabilitado se genera un 0.
Para implementar esto hay que llevar la cuenta del tiempo que estuvo sonando el canal.
Como la onda se repite se puede calcular el módulo (con floats, por eso fmod en lugar de %) para tener un valor entre 0 y el período. Después al dividirlo por el período se obtiene un valor entre 0 y 1, y solo queda compararlo con el duty cycle para saber que valor tiene la onda en ese momento.
Por ahora todo lo que conté aplica a los canales 1 y 2, así que podemos usar el mismo código.
En este video están sonando los dos canales y el emulador no funciona a 60FPS así que se notan los cortes raros. También está reproduciendose de a 1 segundo de audio en vez de a 1 frame, así se que nota el delay:
Esa música la conozco!!
Agregué el canal 2 (es casi igual al 1, copypaste 😎). Siguen faltando muchas cosas de cada canal y queriendo limitar los fps ahora anda a menos de 60 y por eso y otro problema hay huecos de milisegundos. pic.twitter.com/zGS71CozsI
Se puede escuchar como los sonidos son reconocibles, mas allá de todos los problemas que ya describí antes.
Una forma rápida de comprobar si lo que se escucha al menos tiene sentido es usar el emulador BGB como hice en otros casos. Por un lado se puede chequear que los valores de memoria coincidan, pero además con las teclas F5, F6, F7 y F8 se pueden apagar y prender cada canal. Apagando los canales 3 y 4 se puede escuchar como es el sonido real.
Conclusión
Por ahora voy a terminar es post acá porque ya es mucha información junta.
Lo único que quiero remarcar es que trabajar con sonido puede parece muy complejo al principio, pero no es tan difícil. Los conceptos son medio complejos y mas abstractos que en otros temas, pero yendo de a poco y viendo muchos ejemplos creo que se puede entender bastante sin necesidad de conocer sobre física o matemática avanzada.
En el próximo voy a hablar sobre como sincronizar bien el audio y reproducir todo a 60FPS constantes, y en el siguiente voy a retomar con mas features de los canales 1 y 2 y a agregar el canal 3.