En la última década, hemos sido testigos de un creciente interés por la retroprogramación . Por supuesto, tenemos grandes juegos modernos hoy en día, y una impresionante calidad de gráficos e inteligencia artificial detrás de ellos. Sin embargo, muchos gamers no pueden dejar de pensar en una Game Boy o una Super Nintendo Entertainment System ( SNES ) con cierto romanticismo. Es el primer amor, nunca lo superas.
Aunque jugar en una consola antigua suele ser mucho más divertido, hoy en día muchas consolas no están fácilmente disponibles, por lo que los jugadores suelen confiar en los emuladores . Pero para los muchos jugadores que también son desarrolladores, surge una pregunta: ¿cómo se desarrollan los emuladores ? ¿Es fácil o no?
Para responder a estas preguntas, Marco Cuciniello , CTO de Becreatives, pronunció un discurso muy interesante durante Codemotion Rome 2019 , explicando cómo funcionan los emuladores y cómo es posible implementarlos. Si bien esto generalmente requiere lenguajes de bajo nivel como C o C ++, en aras de la claridad, explicó cómo desarrollar un emulador muy simple en Python .
¿Qué es un emulador?
Básicamente, un emulador es un software que puede leer e interpretar el contenido de un archivo de juego, también conocido como ROM , que contiene toda la lógica comercial del juego. Tales ROM generalmente se leen de cartuchos o CD y se descargan en archivos para que sean fáciles de transportar y distribuir. Lo que contiene una ROM es un conjunto de instrucciones en código de máquina (fácilmente convertido en ensamblador , si es necesario), que representa todo el juego.
El papel del emulador es, por lo tanto, actuar como una computadora, implementando una CPU virtual (con el conocido ciclo fetch-decode-execute ), memoria y registros (incluidos tanto el registro de datos como el contador del programa). La memoria se puede implementar como una matriz, los registros son variables y todas las instrucciones deben leerse y decodificarse antes de ejecutarse finalmente.

Por supuesto, arquitecturas simples como las de las primeras consolas son más fáciles de implementar. ¡No puedes comparar la complejidad de una consola de primera generación como la Ping-O-Tronic con una Sony Playstation! Para nuestros propósitos, a continuación consideraremos una máquina extremadamente simple basada en la arquitectura de CHIP-8 , una máquina virtual simple que ha sido ampliamente utilizada con fines didácticos y que puede ejecutar juegos muy simples. Una máquina CHIP-8 se basa en instrucciones de 2 bytes , 16 registros de datos de 8 bits nombrados de V0 a VF y una memoria de 4096 ubicaciones de 8 bits . La referencia técnica completa para CHIP-8 , incluida laconjunto de instrucciones , está disponible en este enlace .
Un emulador de CHIP-8 en Python
Durante su charla, Marco Cuciniello explicó cómo implementar un emulador para la arquitectura CHIP-8 usando Python. Por supuesto, una implementación adecuada requeriría un lenguaje de programación más eficiente , posiblemente compilado y no interpretado, como por ejemplo C o C++ . Como se explicó en la introducción, la lógica detrás de elegir Python es explotar su legibilidad y simplicidad.
Memoria
La implementación de la memoria es probablemente lo más simple que se puede hacer aquí. Solo necesitamos una lista de 4096 valores, que es solo una línea de código en Python:

def load_rom(self, path):
rom = open(path, 'rb').read()
for index, val in enumerate(rom):
self.memory[0x200 + index] = val
Registros
Una vez aclarado el uso de la memoria , es importante ver cómo implementar los registros. CHIP-8 requiere 16 registros de 8 bits , que se pueden implementar de la siguiente manera:
V = [0] * 16
Además de estos, existen otros dos registros: el contador de programa (PC) y un registro adicional (I) utilizado para las instrucciones que involucran memoria :
PC = 0
I = 0
Instrucciones
Ahora que tenemos claro cómo implementar la memoria y los registros, podemos echar un vistazo a las instrucciones. El primer problema a resolver es cómo recuperar la siguiente instrucción de la memoria . Podemos proceder de la siguiente manera:
self.opcode = (self.memory[self.PC] << 8) |
self.memory[self.PC + 1]
Por lo tanto, la instrucción se guarda en self.opcode . Para usarlo correctamente, debemos decodificarlo , lo que significa que debemos entender lo que significa y ejecutar las acciones adecuadas. Las instrucciones de CHIP-8 se componen de 2 bytes y pueden tener diferentes formatos (nuevamente, puede encontrar la lista completa en este enlace ). Generalmente, los primeros bits nos permiten entender qué debe hacer la instrucción, mientras que los últimos bits contienen los datos (por ejemplo, números de registro o direcciones) que debe utilizar dicha instrucción.
Hay varios formatos para las instrucciones. Por ejemplo, el formato de una LLAMADA es 0x2NNN (por ejemplo, 0x22F6), donde NNN es la dirección a la que se llamará. Las instrucciones de asignación tienen en cambio un formato diferente, el cual 0xXY0, donde X representa el número de registro de destino e Y es el número del registro que contiene los datos a asignar. Otras instrucciones tienen un formato como 0x3XNN. Las siguientes líneas de código nos permiten dividir la instrucción en los diferentes campos que la componen:
self.arg_x = (self.opcode & 0x0f00) >> 8
self.arg_y = (self.opcode & 0x00f0) >> 4
self.arg_xnnn = self.opcode & 0xfff
self.arg_xxnn = self.opcode & 0x00ff
self.arg_xxxn = self.opcode & 0x000f
Con base en las variables anteriores, podemos, por ejemplo, decodificar una instrucción de asignación como 0x8230 de la siguiente manera:
self.V[self.arg_x] = self.V[self.arg_y]
Debería ver ahora que implementar todos los diferentes tipos de instrucciones es un proceso muy largo y también, si en principio no es tan difícil, requiere mucha paciencia y atención, ya que los errores están a la vuelta de la esquina.
Mostrar
Una de las partes más importantes de un juego es la gestión de la pantalla, ya que representa la interacción principal para el jugador . CHIP-8 requería una pantalla monocromática de 64×32, que se puede implementar fácilmente con la ayuda de una biblioteca externa en nuestro lenguaje de programación. En el caso de Python , podemos decidir confiar en Pyxel :
def update(self):
self.display_change = False
self.fetch()
self.decode()
self.execute()
Teclado
Por último, pero no menos importante, debemos proporcionar a los jugadores una forma de enviar comandos al juego. En otras palabras , necesitamos usar el teclado, integrando su uso en nuestro emulador . El teclado de CHIP-8 tiene 16 teclas (0-9, AF) que podemos asignar a los códigos de teclas disponibles en Pyxel:
keys_dict = {
0x0: pyxel.KEY_KP_0,
0x1: pyxel.KEY_KP_1,
0x2: pyxel.KEY_KP_2,
0x3: pyxel.KEY_KP_3,
0x4: pyxel.KEY_KP_4,
0x5: pyxel.KEY_KP_5,
0x6: pyxel.KEY_KP_6,
0x7: pyxel.KEY_KP_7,
0x8: pyxel.KEY_KP_8,
0x9: pyxel.KEY_KP_9,
0xA: pyxel.KEY_A,
0xB: pyxel.KEY_B,
0xC: pyxel.KEY_C,
0xD: pyxel.KEY_D,
0xE: pyxel.KEY_E,
0xF: pyxel.KEY_F,
}
Algunas de las instrucciones de CHIP-8 tienen como objetivo permitir la interacción basada en el teclado. Por ejemplo, 0xE09E significa que si se presiona una tecla con un valor en V0, el control debe pasar a la siguiente instrucción . Otro ejemplo es 0xF00A, que detiene la ejecución hasta que se presiona una tecla.
Conclusión
Ahora debería tener una mejor visión general sobre cómo funciona un emulador y cómo implementarlo. Por supuesto, CHIP-8 es probablemente la arquitectura más simple que puede implementar en un emulador . Sin embargo, aunque es conceptualmente simple, crear un emulador de CHIP-8 que funcione completamente desde cero está lejos de ser sencillo; probablemente tendrá que luchar con la depuración e implementar un montón de instrucciones diferentes . Llevar a cabo un proyecto simple como este no es solo un punto de partida, sino que ayuda a comprender la amplia gama de problemas que se encuentran detrás de la tarea de construir cualquier emulador .