Si desarrollas un emulador, los tests automatizados son casi imprescindibles para evitar regresiones. Una aproximación habitual son las pruebas unitarias. Verifican partes del código de forma aislada y, por ello, deben adaptarse específicamente al emulador en cuestión. Como complemento, las pruebas de integración verifican el comportamiento del emulador como un todo.

ROMs de prueba como pruebas de integración

Una modalidad especialmente interesante de pruebas de integración son las ROMs de prueba. Se ejecutan directamente en la plataforma emulada y comprueban de forma dirigida su comportamiento. Se podría decir que prueban el hardware “desde dentro”.

Una ROM de prueba para Game Boy podría, por ejemplo:

Suites de pruebas conocidas

Ya existen colecciones completas de ROMs de prueba para Game Boy, las llamadas suites de pruebas. Una de las más conocidas es la Mooneye Test Suite, desarrollada por Joonas Javanainen (Gekkio). También SameSuite de Lior Halphon y Mealybug Tearoom Tests de Matt Currie son muy recomendables. Además hay muchas otras, que por motivos de espacio no puedo listar aquí.

En uno de mis repositorios de GitHub he reunido una colección de suites de pruebas de varios autores.

Ventajas e inconvenientes de las ROMs de prueba frente a las pruebas unitarias

La barrera de entrada de las ROMs de prueba para Game Boy es notablemente más alta que la de las unitarias. Como pruebas de integración, son más costosas de ejecutar: hay que iniciar el emulador completo, cargar y ejecutar la ROM de prueba y, después, mediante una evaluación adecuada (p. ej., comparando pantalla, memoria o registros de CPU), determinar el resultado. Además, crear ROMs de prueba es más complejo, pues se necesita un toolchain específico, compilar las ROM por separado y, por lo general, escribir en ensamblador, un lenguaje poco extendido.

A cambio, las ROMs de prueba tienen ventajas claras:

  1. Son independientes de la implementación del emulador. Cualquier emulador de la plataforma puede probarse con ellas.
  2. Se ejecutan en hardware original y cualquiera puede verificar allí su corrección.

Desarrollo de ROMs de prueba para Game Boy

Para desarrollar ROMs de prueba de Game Boy se necesita un toolchain adecuado. Mi favorito es RGBDS en combinación con un Makefile. RGBDS está pensado específicamente para desarrollo en Game Boy e incluye ensamblador y enlazador, además de herramientas para conversión de gráficos y fijado de cabeceras. Proyectos pequeños pueden desarrollarse incluso con rgbds-live directamente en el navegador.

Alternativas conocidas a RGBDS son WLA DX (ensamblador cruzado para varias CPU) y GBDK (compilador C para software de Game Boy).

Mientras que RGBDS y WLA DX requieren ensamblador, GBDK también soporta C. En especial para quienes empiezan con Game Boy, GBDK puede ser el camino más sencillo. Sin embargo, al usar C no se controla de forma directa qué instrucciones de CPU genera el compilador. Para ROMs de prueba, que a menudo necesitan temporización al ciclo, el ensamblador es la opción preferible.

Ejecución en hardware real

Para probar tu software en un Game Boy real necesitas un adaptador adecuado. Actualmente uso el EZ-FLASH Junior, que se carga mediante tarjeta micro SD. Hoy en día existen otros adaptadores similares. Una búsqueda de “Game Boy Cartridge Adapter” en Google o Amazon ofrece opciones adecuadas.

Casi todos los adaptadores pueden exponer varias ROM a la vez. En el Game Boy, al arrancar, eliges la ROM que quieres ejecutar.

Portátil con tarjeta micro SD, EZ-Flash Junior y menú de selección en un Game Boy Color

Portátil con tarjeta micro SD, EZ-Flash Junior y menú de selección en un Game Boy Color

Un ROM de prueba de ejemplo sencillo

Para ilustrarlo, veamos una ROM de prueba (muy simple) sobre el DIV-timing. Comprueba el ciclo de reloj del primer incremento de DIV tras un reinicio de DIV y consta de tres partes:

  1. Inicialización de VRAM y BGP
  2. Ejecución de la prueba propiamente dicha
  3. Visualización del resultado (✓ / ╳)

Para probarla, copia el siguiente código del Listado 1 y ejecútalo en rgbds-live (sustituye por completo el contenido prellenado de main.asm). Para forzar que falle, puedes cambiar, por ejemplo, el valor numérico en la línea 48 o 54.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
; main.asm
;
; Esta ROM de prueba, deliberadamente simple, comprueba el ciclo
; del primer incremento de DIV tras un reinicio de DIV.

INCLUDE "hardware.inc"

SECTION "Header", ROM0[$100]
  jp EntryPoint

SECTION "EntryPoint", ROM0[$150]
EntryPoint:
  ; ----- 1. Inicialización de VRAM y BGP
  ;
  ; esperar a V-Blank para apagar el LCD
: ldh a, [rLY]
  cp LY_VBLANK
  jr c, :-
  ; apagar LCD
  ld a, LCDC_OFF
  ldh [rLCDC], a
  ; inicializar VRAM
  ld de, VramData
  ld hl, STARTOF(VRAM)
  ld bc, VramData.end - VramData
: ld a, [de]
  inc de
  ld [hli], a
  dec bc
  ld a, b
  or a, c
  jr nz, :-
  ; establecer la paleta de BG
  ld a, %11_10_01_00
  ldh [rBGP], a

  ; ----- 2. Ejecución de la prueba
  ;
MACRO NOPS
  REPT \1
    nop
  ENDR
ENDM
  ; leer rDIV un ciclo M antes del incremento
  ldh [rDIV], a ; reiniciar rDIV, reiniciar contador
  NOPS 60
  ldh a, [rDIV]
  cp a, 0       ; rDIV == 0 justo antes del primer incremento
  jr nz, .fail
  ; leer rDIV en el ciclo M del incremento
  ldh [rDIV], a ; reiniciar rDIV, reiniciar contador
  NOPS 61
  ldh a, [rDIV]
  cp a, 1       ; rDIV == 1 en el primer incremento
  jr nz, .fail

  ; ----- 3. Mostrar el resultado (✓ o ╳)
  ;
  ; Prueba correcta: ✓
  ld a, 1
  jr .finish
  ; Prueba fallida: ╳
.fail:
  ld a, 2
.finish:
  ; Mostrar resultado: ✓ o ╳
  ld [STARTOF(VRAM) + $1800], a
  ld a, LCDC_ON | LCDC_BLOCK01 | LCDC_BG_ON
  ldh [rLCDC], a
: jr :-

SECTION "VRAM data", ROM0
VramData:
  ; Tile 0: vacío
  ds 16, 0
  ; Tile 1: ✓
  dw `00000000
  dw `00000033
  dw `00000333
  dw `00000330
  dw `03303300
  dw `03333300
  dw `00333000
  dw `00033000
  ; Tile 2: ╳
  dw `00000000
  dw `03300033
  dw `03330333
  dw `00333330
  dw `00033300
  dw `00333330
  dw `03330333
  dw `03300033
  ; VRAM restante
  ds $2000 - 3 * 16, 0
.end

Listado 1: una ROM de prueba sencilla para probar en rgbds-live

Implementación de un framework de pruebas

La ROM de prueba anterior está compuesta en gran parte por inicialización y presentación del resultado. La lógica de la prueba, en sí, ocupa unas 20 de casi 100 líneas. En una suite completa, ese boilerplate se repetiría en cada ROM de prueba. Por ello, conviene extraer esas partes a un framework de pruebas que se incluya desde todas las ROM.

Normalmente, una ROM de prueba incluye el código del framework antes de su propio código de test. El framework asume el arranque y la inicialización y ofrece rutinas comunes que la suite utiliza con frecuencia. Las suites ya mencionadas Mooneye Test Suite, SameSuite y Mealybug Tearoom Tests usan este enfoque.

Evaluación del resultado en la ROM de prueba

Si extraemos inicialización y presentación del resultado de la ROM de ejemplo a un framework, queda el Listado 2. Tras la inicialización que hace el framework, la ROM de prueba ejecuta su test como siempre. La evaluación del resultado también permanece en la ROM: en caso de error se llama a TestFail para finalizar (líneas 16 y 23), y en caso de éxito a TestSuccess (línea 26).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
; main.asm

INCLUDE "test-framework.inc"

MACRO NOPS
  REPT \1
    nop
  ENDR
ENDM

  ; leer rDIV un ciclo M antes del incremento
  ldh [rDIV], a ; reiniciar rDIV, reiniciar contador
  NOPS 60
  ldh a, [rDIV]
  cp a, 0
  jp nz, TestFail

  ; leer rDIV en el ciclo M del incremento
  ldh [rDIV], a ; reiniciar rDIV, reiniciar contador
  NOPS 61
  ldh a, [rDIV]
  cp a, 1
  jp nz, TestFail
  
  ; Prueba correcta
  jp TestSuccess

Listado 2: código de la ROM de ejemplo con framework de pruebas incluido

El framework (Listado 3) proporciona, además del arranque y la inicialización, las rutinas TestSuccess y TestFail. También se encarga de mostrar el resultado.

Con este enfoque, cada test es más conciso y fácil de mantener. A la vez, la ROM de prueba sigue siendo completamente libre en la forma de evaluar y puede abortar la prueba en cuanto detecte un problema (“fail fast”).

La Mooneye Test Suite está implementada así.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
; test-framework.inc

INCLUDE "hardware.inc"

SECTION "Header", ROM0[$100]
  jp EntryPoint

SECTION "Utilities", ROM0[$150]
; Prueba correcta: mostrar ✓
TestSuccess:
  ld a, 1 ; ✓
  jr Finish

; Prueba fallida: mostrar ╳
TestFail:
  ld a, 2 ; ╳
  jr Finish

Finish:
  ld [STARTOF(VRAM) + $1800], a
  ld a, LCDC_ON | LCDC_BLOCK01 | LCDC_BG_ON
  ldh [rLCDC], a
: jr :-

VramData:
  ; Tile 0: vacío
  ds 16, 0
  ; Tile 1: ✓
  dw `00000000
  dw `00000033
  dw `00000333
  dw `00000330
  dw `03303300
  dw `03333300
  dw `00333000
  dw `00033000
  ; Tile 2: ╳
  dw `00000000
  dw `03300033
  dw `03330333
  dw `00333330
  dw `00033300
  dw `00333330
  dw `03330333
  dw `03300033
  ; VRAM restante
  ds $2000 - 3 * 16, 0
.end

SECTION "EntryPoint", ROM0
EntryPoint:
  ; esperar a V-Blank para apagar el LCD
: ldh a, [rLY]
  cp LY_VBLANK
  jr c, :-
  ; apagar LCD
  ld a, LCDC_OFF
  ldh [rLCDC], a
  ; inicializar VRAM
  ld de, VramData
  ld hl, STARTOF(VRAM)
  ld bc, VramData.end - VramData
: ld a, [de]
  inc de
  ld [hli], a
  dec bc
  ld a, b
  or a, c
  jr nz, :-
  ; inicializar paleta de BG
  ld a, %11_10_01_00
  ldh [rBGP], a

  ; a partir de aquí toma el control la prueba

Listado 3: framework de pruebas usado por la ROM de ejemplo

Evaluación del resultado en el framework

Nuestra ROM de ejemplo solo ha comprobado dos puntos de datos: el valor del registro DIV justo antes y justo durante su primer incremento. Es manejable y puede evaluarse directamente en la ROM. Pero si los tests generan cientos o miles de puntos de datos, compararlos dentro de la ROM se vuelve incómodo.

Una alternativa es realizar el comparado en el framework. Para ello, el framework reserva una región en el WRAM. Cada ROM de prueba deposita allí los valores realmente medidos y, además, entrega los resultados esperados. El framework compara ambos y muestra a continuación éxito o fallo.

La ventaja es que las ROMs de prueba pueden ser aún más concisas, ya que no necesitan encargarse de la evaluación.

La desventaja es que el test debe ejecutarse completo antes de tener un resultado. No es posible un “fail fast”, pues eso requeriría evaluar dentro de la ROM.

Así funcionan, por ejemplo, SameSuite y Mealybug Tearoom Tests.

Uso en pruebas de integración automatizadas

Para ejecutar la ROM de ejemplo como prueba de integración automatizada, hay que cargarla en el emulador a verificar y ejecutarla durante un tiempo determinado. Al final, el resultado se comprueba de forma automatizada. Para que esto funcione de manera fiable, hay que responder dos puntos abiertos:

  1. ¿Cuánto tiempo debe ejecutarse la ROM de ejemplo hasta que haya un resultado?
  2. ¿Cómo se comprueba automáticamente el resultado, es decir, éxito o fallo?

Estas preguntas, por supuesto, se plantean para cualquier ROM de prueba.

Convención actual

Mooneye Test Suite, SameSuite y Mealybug Tearoom Tests siguen la misma convención. Aquí probablemente fue determinante Mooneye Test Suite, que originalmente formaba parte del emulador Mooneye GB.

  1. Un test se considera finalizado en cuanto se ejecuta la instrucción ld b, b. Como esa instrucción no tiene efecto salvo consumir un ciclo, no se usa en código normal.

  2. Un test exitoso se señala cargando en los registros de CPU un fragmento de la sucesión de Fibonacci:

    • B = 3
    • C = 5
    • D = 8
    • E = 13
    • H = 21
    • L = 34

    Cualquier otro conjunto de valores implica que el test no ha tenido éxito.

Nuestra ROM de ejemplo cumple esta convención si el framework se adapta como en el Listado 4.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
; test-framework.inc

; < ... >

; Prueba correcta: mostrar ✓
TestSuccess:
  ld a, 1 ; ✓
  ld b, 3 ; señalar "prueba correcta" mediante registros de CPU
  ld c, 5
  ld d, 8
  ld e, 13
  ld h, 21
  ld l, 34
  jr Finish

; Prueba fallida: mostrar ╳
TestFail:
  ld a, 2 ; ╳
  ld b, 0 ; señalar "prueba fallida" mediante registros de CPU
  jr Finish

Finish:
  ld b, b ; señalar "prueba finalizada"
  ld [STARTOF(VRAM) + $1800], a
  ld a, LCDC_ON | LCDC_BLOCK01 | LCDC_BG_ON
  ldh [rLCDC], a
: jr :-

; < ... >

Listado 4: fragmento del framework a ajustar para señalar el resultado de la prueba

Resultados análogos

No todas las ROMs de prueba pueden evaluar su resultado por sí mismas. Un ejemplo es la salida de vídeo: la ROM puede generar la imagen, pero no comprobar si se muestra correctamente.

En estos casos, la ROM de prueba solo señala con ld b, b que el test ha finalizado. La evaluación la realiza el código que ejecuta la prueba de integración. Para ello se adjunta a la ROM de prueba una captura de referencia, que se compara con la salida real del emulador.

También existe una convención para los valores de color en estas capturas:

  • Capturas DMG usan estos colores:
    #000000, #555555, #AAAAAA, #FFFFFF
  • Capturas CGB convierten los colores CGB de 15 bits por canal R/G/B a 8 bits:
    (X << 3) | (X >> 2)
  • Capturas en “Non-CGB Mode” usan estos colores:
    • BGP: #000000, #0063C6, #7BFF31, #FFFFFF
    • OBP: #000000, #943939, #FF8484, #FFFFFF

Conclusiones

  • Las ROMs de prueba son ideales para pruebas de integración de emuladores: verifican el comportamiento del hardware emulado y se pueden ejecutar tanto en emuladores como en hardware original.
  • En comparación con las unitarias, son más costosas de crear y ejecutar, pero una vez implementadas pueden reutilizarse en cualquier emulador.
  • Para temporización precisa, el ensamblador es preferible a C.
  • Un framework de pruebas reduce el boilerplate y facilita enormemente el desarrollo de suites grandes.
  • Con herramientas como RGBDS, WLA DX o GBDK y adaptadores adecuados (p. ej., EZ-FLASH Junior), es posible cubrir todo el flujo desde el desarrollo hasta la prueba en hardware real.