Programación de la NES: desarrollo de un juego sencillo 
La consola Nintendo Entertainment System (NES) fue una consola lanzada por Nintendo en 1983 y fue su primera consola de sobremesa basada en cartuchos de juegos. Está equipada con el procesador de 8 bits MOS6502 (mismo procesador que la Atari 2600, que el Apple II y que otros ordenadores de principios de los 80). A lo largo de esta entrada intentaré explicar de forma sencilla cómo programarla en C desarrollando un sencillo juego de buscaminas usando el compilador SDCC.

La NES dispone de una CPU y una PPU (Picture Processing Unit) con memorias separadas. La CPU accede a:

- 2 Kb de memoria RAM interna de la consola.

- Registros específicos para acceder a la PPU, la APU (audio) y E/S.

- Cartucho (ROM y, opcionalmente, RAM).

La PPU accede a:

- 2 Kb de memoria de vídeo.

- Memoria de paletas.

- Memoria de sprites.

- Otra parte del cartucho donde puede haber almacenadas baldosas, por ejemplo o RAM adicional para la PPU, aunque suele ser ROM (denominada CHR-ROM).

Como se puede ver, los cartucho NES traen, por lo general mínimo dos chips ROM: uno con el programa para la CPU (el código del juego) y un chip con las baldosas para ser mapeado directamente en la memoria de vídeo de la PPU. Esta es la configuración más sencilla y en la que nos centraremos nosotros.

Mapa de memoria

La NES permite una cantidad enorme de mapeados de memoria en los cartuchos tanto para la CPU como para la PPU. Documentar los diferentes mapeos en esta entrada sería excesivo así que me centraré en el mapeador "NROM" (https://www.nesdev.org/wiki/NROM) por ser el más sencillo:

- 16 ó 32 Kb de memoria de programa PRG-ROM.

- 8 Kb de memoria de caracteres (o baldosas) CHR-ROM.

Este mapper no requiere hacer bank-switching: toda la memoria de programa PRG-ROM se mapea directamente a partir de la dirección de memoria 0x8000 de la CPU (hasta los 32 Kb) y toda la memoria de caracteres o baldosas CHR-ROM se mapea directamente en los primeros 8 Kb de la memoria de vídeo (accesible por la PPU como "pattern table 0" y "pattern table 1").

El mapa de memoria accesible por la CPU es el siguiente (bus de direcciones de 16 bits):

DesdeHastaTamañoDescripción
0x00000x07FF0x0800RAM interna NES (2 Kb)
0x20000x20070x0008Registros PPU
0x40000x40170x0018Registros APU (audio) y E/S
0x40200xFFFF0xBFE0Cartucho
0x6000 - 0x7FFF para RAM en el cartucho
0x8000 - 0xFFFF para ROM de programa


Poner RAM en el cartucho es opcional y sólo lo hacen algunos juegos que permiten guardar partidas usando una RAM no volátil, por ejemplo.

El mapa de memoria accesible por la PPU es el siguiente (bus de direcciones de 14 bits):

DesdeHastaTamañoDescripción
0x00000x0FFF0x1000Pattern table 0 (4 Kb) en el cartucho
0x10000x1FFF0x1000Pattern table 1 (4 Kb) en el cartucho
0x20000x23BF0x03C0Name table 0 (32 x 30 bytes, uno por baldosa)
0x23C00x23FF0x0040Attribute table 0 (paletas name table 0)
0x24000x27BF0x03C0Name table 1 (32 x 30 bytes, uno por baldosa)
0x27C00x27FF0x0040Attribute table 1 (paletas name table 1)
0x28000x2BBF0x03C0Name table 2 (32 x 30 bytes, uno por baldosa)
0x2BC00x2BFF0x0040Attribute table 2 (paletas name table 2)
0x2C000x2FBF0x03C0Name table 3 (32 x 30 bytes, uno por baldosa)
0x2FC00x2FFF0x0040Attribute table 3 (paletas name table 3)
0x3F000x3F1F0x0020Paletas internas de la PPU


Los 8 Kb (4 + 4) correspondientes a las "pattern tables" (o baldosas en sí) se mapean en el cartucho (ya sea como ROM o como RAM). En nuestro caso, con el mapeador NROM, se mapean como una ROM (llamada CHR-ROM) de 8 Kb que almacena las dos "pattern tables" seguidas una después de la otra.
El resto de la memoria de vídeo son:

- 4 Kb correspondientes a las 4 "name tables". Realmente la PPU sólo dispone de 2 Kb y en un registro de la misma se establece cómo de hace el mirroring y se disponen para el caso de que se quiera hacer efectos de scroll, por ejemplo. No será nuestro caso.

- 32 bytes correspondientes a las paletas de color internas de la PPU.

- 256 bytes correspondientes a los sprites (64 sprites, 4 bytes por sprite) que no aparecen en la tabla anterior puesto que no se encuentran direccionados en una zona concreta de la VRAM sino en unos registros aparte de la PPU.

Con este mapper "NROM" podemos hacer juegos que no requieran alterar las "pattern tables" (las baldosas) ya que dichas baldosas estarán en una ROM del cartucho (CHR-ROM) de 8 Kb. En la práctica esta limitación no es importante pues rara vez necesitaremos modificar las baldosas a nivel de pixel y muchos juegos comerciales de NES utilizan este mapeo. En caso de que necesitemos que toda o parte de la memoria de caracteres (baldosas) sea RAM, tendríamos que utilizar otro tipo de mapper que no sea NROM.

Fichero .nes

Como lo que queremos al final es poder jugar a los juegos que hagamos para la NES, necesitamos que el proceso de compilación genere un fichero ".nes" que pueda ser ejecutado en un emulador o incluso cargado en un cartucho flash de una NES real. Para el mapeador "NROM" si lo que queremos es una ROM de programa (PRG-ROM) de 32 Kb más una memoria ROM de baldosas (CHR-ROM) de 8 Kb (para almacenar las "pattern tables" 0 y 1) el fichero tendrá que tener el siguiente formato interno:

DesdeHastaTamañoContenido
0x00000x000F0x00104e 45 53 1a 02 01 00 00 00 00 03 00 00 00 00 00
0x00100x800F0x8000PRG-ROM o ROM del programa (32 Kb)
0x80100xA00F0x2000CHR-ROM o ROM de caracteres (baldosas) (8 Kb)
Total0xA010


La cabecera y la disposición de los datos vienen explicados con más detalle en https://www.nesdev.org/wiki/INES y no me pararé a explicar lo que significa cada byte. Por ahora baste saber que con esa cabecera tenemos un fichero ".nes" con mapeador NROM, 32 Kb de memoria de programa y 8 Kb de memoria de baldosas (CHR-ROM). Al final el tamaño del fichero ".nes" generado en nuestro caso será siempre de 0xA010 bytes (40976 bytes).

Secuencia de arranque

La NES asume el siguiente contenido en estas posiciones de la memoria accesible por la CPU:

DesdeHastaTamañoDescripción
0xFFFA0xFFFB2Puntero a rutina de servicio de NMI (interrupción no enmascarable)
0xFFFC0xFFFD2Puntero a código de arranque
0xFFFE0xFFFF2Puntero a rutina de servicio de IRQ (interrupción enmascarable)


Como nosotros usaremos el mapper NROM, podemos hacer una ROM de 32 Kb que se alojará a partir de la dirección 0x8000 y en los últimos 6 bytes de esa ROM podremos esos tres punteros indicados en la tabla.

A continuación puede verse el detalle del fichero "crt0nes.s" utilizado:

    .module crt0nes
.globl _main ; programmer must define these functions in C
.globl _irq_isr
.globl _nmi_isr
.area _HEAD (ABS)

.org 0x8000

jmp init

jmp _irq_isr

jmp _nmi_isr

init:
sei ; ignore IRQs
cld ; disable decimal mode
ldx #0x40
stx 0x4017 ; disable APU frame IRQ
ldx #0xff
txs ; set stack pointer to $01ff
inx ; now X = 0
stx 0x2000 ; disable NMI
stx 0x2001 ; disable rendering
stx 0x4010 ; disable DMC IRQs

; The vblank flag is in an unknown state after reset,
; so it is cleared here to make sure that @vblankwait1
; does not exit immediately.
bit 0x2002

; First of two waits for vertical blank to make sure that the
; PPU has stabilized
vblankwait1:
bit 0x2002
bpl vblankwait1

; We now have about 30,000 cycles to burn before the PPU stabilizes.
; One thing we can do with this time is put RAM in a known state.
; Here we fill it with $00, which matches what (say) a C compiler
; expects for BSS. Since we haven't modified the X register since
; the earlier code above, it's still set to 0, so we can just
; transfer it to the Accumulator and save a byte
txa
clrmem:
sta 0x00,x
sta 0x100,x
sta 0x200,x
sta 0x300,x
sta 0x400,x
sta 0x500,x
sta 0x600,x
sta 0x700,x
inx
bne clrmem

vblankwait2:
bit 0x2002
bpl vblankwait2

; init global variables
jsr gsinit
; main function
jsr _main
inf_loop:
jmp inf_loop

; ordering of segments for the linker.
.area _HOME
.area _CODE
.area CODE
.area _INITIALIZER
.area _GSINIT
gsinit::
.area _GSFINAL
rts

.area RODATA

.area _TAIL (ABS)

.org 0xFFFA
.dw #0x8006

.org 0xFFFC
.dw #0x8000

.org 0xFFFE
.dw #0x8003

Como se puede comprobar en las posiciones de memoria finales se incluyen esos tres punteros descritos. El programador está obligado a definir en C las funciones:

- "irq_isr": para atender la interrupción enmascarable (relacionada con el audio).

- "nmi_isr": para atender la interrupción no enmascarable (asociada al vblank de la PPU).

- "main": para escribir el código de arranque del juego.

De la siguiente manera:

void irq_isr(void) __interrupt {
    // atender interrupción de audio
}

void nmi_isr(void) __interrupt {
    // atender interrupción de retrazo vertical (vblank)
}

void main(void) {
    // código principal del juego
}


Interrupciones

La CPU de la NES tiene 2 fuentes de interrupción:

-NMI (interrupción no enmascarable): no puede deshabilitarse y se dispara en cada retrazo vertical de la pantalla (60 veces por segundo, después de cada frame, en sistemas de televisión NTSC y 50 veces por segundo en sistemas de televisión PAL). La genera la PPU y, aunque no puede deshabilitarse desde la CPU, sí puede configurarse la PPU para que la genere o no. Lo normal es usarla y basar el timing de nuestro juego en esta interrupción.

- IRQ (interrupción enmascarable): puede deshabilitarse y habilitarse desde la CPU y normalmente está asociada a la APU, aunque algunos mappers y periféricos también la utilizar. En nuestro caso, como no la usaremos, definimos la función "irq_isr" vacía.

Compilar el compilador

Para programar en C el 6502 hay varios compiladores y proyectos pero yo siempre he tenido "debilidad" por el SDCC, por ser un compilador muy completo, con licencia GPL y con buen soporte por parte de la comunidad. Compilar el compilador SDCC es muy sencillo, basta descargarlo de https://sdcc.sourceforge.net/, decomprimirlo y realizar los siguientes pasos:

$ cd /ruta/fuente/sdcc
$ ./configure --prefix=/ruta/destino/sdcc --disable-pic14-port --disable-pic16-port
$ make
$ make install

Ahora ya tendremos instalado el SDCC en la carpeta "/ruta/destino/sdcc".

Ejemplo sencillo

Como ejemplo sencillo tenemos que partir de una CHR-ROM que hagamos nosotros o que esté ya hecha y podamos usar. La web https://jimmarshall35.github.io/TileEditor/index.html permite cargar ROM de NES y extraerles y editarles la CHR-ROM. En mi caso he extraido la CHR-ROM del juego homebrew "Thwaite" que tiene licencia GPL. La CHR-ROM de este juego tiene la ventaja de que las baldosas 32 a la 127 mapean los caracteres imprimibles del alfabeto ASCII.

#define  PPUCTRL    *((volatile uint8_t *) 0x2000)
#define  PPUMASK    *((volatile uint8_t *) 0x2001)
#define  PPUSTATUS  *((volatile uint8_t *) 0x2002)
#define  OAMADDR    *((volatile uint8_t *) 0x2003)
#define  OAMDATA    *((volatile uint8_t *) 0x2004)
#define  PPUSCROLL  *((volatile uint8_t *) 0x2005)
#define  PPUADDR    *((volatile uint8_t *) 0x2006)
#define  PPUDATA    *((volatile uint8_t *) 0x2007)

void irq_isr(void) __interrupt {
}

volatile uint16_t v;

void nmi_isr(void) __interrupt {
    PPUSTATUS;
    PPUADDR = 0x20;
    PPUADDR = 0x05;
    PPUDATA = (v >> 3) & 0x00FF;   // change some tile each 2^3 = 8 frames
    PPUCTRL = 0x80;   // base nametable = 0x2000, VRAM increment = 1, sprite pattern table = 0, background pattern table = 0, sprite size = 8x8, enable VBlank NMI
    PPUSTATUS;
    PPUSCROLL = 0x00;
    PPUSCROLL = 0x00;
    v++;
}

const char STR_1[] = "TEST1";
const char STR_2[] = "TEST2";

void show_str(const char *p) {
    while (*p != 0) {
        PPUDATA = *p;
        p++;
    }
}

void main(void) {
    v = 0;
    // disable rendering
    PPUCTRL = 0x00;
    PPUMASK = 0x00;
    // put STR_1 and STR_2 string in nametable (at 1st and 2nd line respectively)
    PPUSTATUS;
    PPUADDR = 0x20;     // STR_1 at 1st line of nametable 0x2000
    PPUADDR = 0x00;
    show_str(STR_1);
    PPUSTATUS;
    PPUADDR = 0x20;     // STR_2 at 2nd line of nametable 0x2000
    PPUADDR = 0x20;
    show_str(STR_2);
    // init palette
    PPUSTATUS;
    PPUADDR = 0x3F;
    PPUADDR = 0x00;
    PPUDATA = 0x0F;   // palette 0, color 0, black
    PPUDATA = 0x12;   // palette 0, color 1, blue
    PPUDATA = 0x2A;   // palette 0, color 2, green
    PPUDATA = 0x30;   // palette 0, color 3, white
    // enable rendering
    PPUMASK = 0x0A;   // color, show background in leftmost 8 pixels, enable background rendering
    PPUCTRL = 0x80;   // base nametable = 0x2000 (bits 1 and 0 = 0), VRAM increment = 1, sprite pattern table = 0, background pattern table = 0, sprite size = 8x8, enable VBlank NMI
    PPUSTATUS;
    PPUSCROLL = 0x00;   // update "t" internal register so top-left at beginning of nametable
    PPUSCROLL = 0x00;
    // infinite loop (wait vblank NMI)
    while (1)
        ;
}

El programa imprime dos cadenas de texto al principio de la "name table" 0, habilita la interrupción NMI (la que se dispara en cada retrazo vertical (VBlank)) para hacer que una baldosa cambie varias veces por segundo.

Para generar la ROM haremos lo siguiente:

# ensamblamos el crt0
$ sdas6500 -o crt0nes.rel crt0nes.s

# compilamos el main.c
$ sdcc -mmos6502 --stack-auto -c -o main.rel main.c

# enlazamos todo y generamos el ejecutable main.ihx (formato intel hex)
$ sdcc -mmos6502 --stack-auto --no-std-crt0 -o main.ihx crt0nes.rel main.rel

# generamos el PRG-ROM a partir del intel hex (main.ihx)
$ objcopy -I ihex -O binary main.ihx main.prg-rom

# generamos la ROM en formato ".nes" (como si fuese un cartucho)
$ cat main.header main.prg-rom main.chr-rom > main.nes

Ya podemos ejecutar la ROM "main.nes" con un emulador o pasarlo a un cartucho flash y ejecutarlo en una consola real.



El fichero "main.header" es un fichero de 16 bytes que contiene la cabecera del fichero ".nes" mientras que el fichero "main.chr-rom" es un fichero de 8 Kb que contiene las baldosas extraidas del juego "Thwaite". Ambos ficheros están en el "nes.tar.gz" de la sección soft.

RAM y pila

La pila en la gran mayoría de procesadores de 8 y más bits puede ser alojada en cualquier sitio de la RAM teniendo el puntero de pila la misma cantidad de bits que el bus de direcciones. Ese no es el caso del 6502.

En el 6502 el puntero de pila (registro S) es un registro de 8 bits por lo que la pila del sistema sólo puede almacenar 256 bytes y, además, dicha pila está localizada siempre entre 0x0100 y 0x01FF. En el arranque se recomienda inicializar S a 0xFF ya que la pila crece hacia direcciones de memoria inferiores cuando se hace PUSH (instrucciones "PHA" y "PHP") y viceversa, decrece hacia direcciones de memoria superiores cuando se hace POP (instrucciones "PLA" y "PLP").

Con respecto al resto de la RAM, hay que tener en cuenta que los accesos a la "página 0" (o "Zero Page", ZP) de la memoria (direcciones 0x0000 a 0x00FF) son más rápidos en ciclos de reloj que los accesos al resto de la memoria. Por tanto, lo lógico es intentar que la mayor cantidad posible de variables que se usen con frecuencia se alojen en la página 0 (ZP) entre 0x0000 y 0x00FF. El compilador se encarga de esas cosas aunque el SDCC no es muy "inteligente" y al final las coloca por orden de declaración en el código.

Éstas son algunas de las consideraciones que hay que tener en cuenta a la hora de programar:

- La cantidad de bytes que necesitemos para variables globales determinarán si pueden ser alojadas todas en la página 0 o no. Cuando se excedan los 256 bytes de variables globales se empezarán a alojar después de la pila a partir de 0x0200 (que es una zona de memoria RAM más lenta que la página 0).

- El SDCC en el caso del 6502 trata de optimizar el uso de la pila y este tipo de optimización suele ser incompatible con algunos mecanismos avanzados del lenguaje como punteros a funciones o recursividad. Para estos casos existe la opción "--stack-auto" que hace que la pila sea usada de forma "habitual" como se haría con cualquier otro procesador: permitiendo punteros a funciones y recursividad, con la penalización de que la pila crecerá muy rápido y la profundidad de llamadas anidadas que podamos hacer será limitada.

- Se debe tener en cuenta que las variables locales se alojan en pila por lo que hay que ser cuidadosos a la hora de declararlas y evitar meter objetos o estructuras muy grandes en ellas.

- Las constantes ("const") se alojan en la memoria de programa (PRG-ROM), por lo que no ocupan RAM (ni pila).

Consideraciones especiales con respecto a la VRAM de la PPU

La PPU de la NES es extremadamente sencilla y adolece de un "problema" de diseño que hace que el registro interno utilizado para direccionar la VRAM sea el mismo tanto para acceder desde la CPU como para renderizar.

La CPU se comunica con la PPU escribiendo y leyendo en una serie de registros mapeados en la RAM de la CPU (https://www.nesdev.org/wiki/PPU_registers) por lo que cada vez que la CPU necesite acceder a la VRAM debe escribir en el registro PPUADDR la dirección en la VRAM a la que quiere acceder y a continuación escribir o leer en el registro PPUDATA el byte. Estos accesos a la VRAM por parte de la CPU "ensucian" un registro interno que utiliza la PPU para el renderizado, por lo que, antes de que se empiece a renderizar el siguiente frame, se deben actualizar los registros PPUCTRL y PPUSCROLL (aunque sea para poner un 0 si no hay scroll). De esta forma nos aseguramos que cuando empieza a dibujarse la pantalla se hace desde la coordenada que realmente queremos.

Por ejemplo, si, durante el retrazo vertical queremos escribir en una posición de la VRAM haremos:

PPUSTATUS;     // se fuerza el reset del flip-flop que determina qué parte de la dirección se carga primero
PPUADDR = byte alto de la direccion VRAM
PPUADDR = byte bajo de la direccion VRAM
PPUDATA = primer byte
PPUDATA = segundo byte

// antes de terminar “limpiamos” el registro interno que hemos “ensuciado”
PPUCTRL = 0x80;   // nametable = 0x2000, VRAM increment = 1, sprite pattern table = 0, background pattern table = 0, sprite size = 8x8, enable VBlank NMI
PPUSTATUS;
PPUSCROLL = 0x00;   // sin scroll
PPUSCROLL = 0x00;


Sprites

la PPU de la NES permite trabajar con hasta 64 sprites simultáneamente. Los sprites pueden ser de 8x8 o de 8x16 (en nuestro caso opté por sprites de 8x8 por ser más sencillos de manejar). Cada sprite está definido por 4 bytes (la memoria de sprites de la PPU es de 256 bytes):

- byte 0: coordenada Y: byte sin signo. No se admiten valores negativos.

- byte 1: índice de baldosa a usar

- byte 2: atributos: paleta a utilizar, si está delante o detrás del fondo, si hay que invertir la baldosa horizontalmente y/o verticalmente. Útil para cuando tengamos sprites con algún tipo de simetría y ahorrarnos baldosas.

- byte 3: coordenada X: sin signo. No se admiten valores negativos.

Como en muchas otras consolas, el color 0 es el color transparente, por tanto cada sprite de 8x8 sólo tiene 3 colores más el fondo. Para poder "esconder" suavemente un sprite por el lado izquierdo de la pantalla hay un truco que se usa mucho que es decirle a la PPU que no renderice los 8 pixels de la izquierda de la pantalla para el fondo y/o para los sprites (mediante el registro PPUCTRL que tienes dos bits para esto). "Esconder" sprites por el lado superior (coordenada Y negativa) no era necesario pues en la práctica ni las primeras 8 líneas de arriba ni las últimas 8 líneas de abajo llegaban a ser visibles en la mayoría de las pantallas (ni PAL ni NTSC).

A la hora de manipular y modificar los sprites hay dos formas de hacerlo:

- Accediendo a los registros OAMADDR y OAMDATA para poner la dirección y el dato respectivamente.

- Accediendo mediante DMA (la mejor forma y más eficiente): Se decide una zona de la RAM de la CPU (dentro de los 2 Kb) donde se podrá una copia de los 256 bytes de configuración de los sprites. Esa zona de 256 bytes debe estar alineada con una página de la RAM, es decir, tiene q ser unadirección de memoria de la forma 0xXX00 y, tras escribir en los 256 bytes de esa zona (entre 0xXX00 y 0xXXFF), escribimos en el registro OAMADDR un 0 y en el registro OAMDMA el byte alto de la dirección (OAMDMA = 0xXX).

Por ejemplo, usando el mecanismo de DMA, si decidimos configurar los sprites en la dirección 0x0700 (al final de la RAM), la coordenada Y del sprite 0 estará en 0x0700, el índice de la baldosa del sprite 0 en 0x0701 y así con el resto de sprites hasta la posición 0x07FF que será la coordenada X del sprite 63. De esta forma basta con hacer:

OAMADDR = 0;
OAMDMA = 0x07;

Para que el controlador DMA de la PPU se encargue de mandar todos esos valores más rápido de lo que lo haría la CPU (la cantidad de bytes que se manda es siempre 256 y siempre desde la memoria de la CPU a la memoria de sprites del la PPU, no en sentido contrario).

Este mecanismo es el más rápido y elegante para mover y manipular sprites de forma rápida. Es el que se utiliza en el ejemplo de juego que se describe a continuación.

Desarrollo de un pequeño juego

Se plantea el desarrollo de un Buscaminas (Minesweeper) sencillo. El guión del juego sería el siguiente:


En el que cada nodo será una pantalla del juego. La forma en que se implementará la dinámica y los estados del juego será a través de una estructura de datos que simulará orientación a objetos mediante estructuras con punteros a funciones. Definimos la estructura "screen_t":

struct screen_t {
    void (*on_load)(struct inter_screen_data_t *);
    const struct screen_t *(*on_vblank)(uint8_t, struct inter_screen_data_t *);
    void (*on_unload)(struct inter_screen_data_t *);
};

Dicha estructura define tres punteros a funciones:

- void on_load(struct inter_screen_data_t *): Se invoca justo cuando entramos en una pantalla (nodo del guión) y se encarga de configurarla (definir paletas, baldosas, sprites, variables globales, etc). Debe dejar la pantalla ya preparada y activada. Recibe como parámetro un puntero a una estructura que se usa para pasar valores entre pantallas (nodos del guión).

- const struct screen_t *on_vblank(uint8_t, struct inter_screen_data_t *): Se invoca después de cada retrazo vertical. Recibe por parámetros, además de la estructura "inter_screen_data_t" un byte indicando el estado del controlador (botones A, B, start, select y cruceta, en forma de bits a 0 o a 1). La función devuelve un puntero a una estructura "screen_t" o NULL. En caso de devolver una estructura "screen_t" que no sea la misma que la actual, significa que el juego debe cambiar de pantalla.

- void on_unload(struct inter_screen_data_t *): Se invoca en caso de que "on_vblank" devuelva un puntero a una "screen_t" que no sea la propia y justo antes de invocar el "on_load" de la siguiente pantalla.

Nótese que, al utilizar el mecanismo de punteros a funciones, estamos obligados a utilizar la opción "--stack-auto" del compilador, que produce una penalización en la cantidad de datos que se meten en la pila.

El esqueleto principal del juego quedaría como sigue:

volatile struct inter_screen_data_t isd;

void irq_isr(void) __interrupt {
}

volatile uint8_t vblank;

void nmi_isr(void) __interrupt {
    vblank = 1;
}

const struct screen_t screens[] = {
    {
        title_screen_on_load,
        title_screen_on_vblank,
        title_screen_on_unload
    },
    {
        board_screen_on_load,
        board_screen_on_vblank,
        board_screen_on_unload
    },
    {
        end_screen_on_load,
        end_screen_on_vblank,
        end_screen_on_unload
    }
};

void main(void) {
    vblank = 0;
    const struct screen_t *current_screen = screens;
    current_screen->on_load(&isd);
    uint8_t controller_mask = controller_read();
    // infinite loop (wait vblank NMI)
    while (1) {
        if (vblank) {
            vblank = 0;
            const struct screen_t *next_screen = current_screen->on_vblank(controller_mask, &isd);
            if ((next_screen != NULL) && (next_screen != current_screen)) {
                current_screen = next_screen;
                current_screen->on_load(&isd);
            }
            controller_mask = controller_read();
        }
    }
}

Para cada nodo del guión del juego definimos un ".h" y un ".c" que definirá como mínimo esas tres funciones para ese nodo. Por ejemplo, para la pantalla de título tenemos el fichero "title_screen.h":

void title_screen_on_load(struct inter_screen_data_t *d);
const struct screen_t *title_screen_on_vblank(uint8_t controller_mask, struct inter_screen_data_t *d);
void title_screen_on_unload(struct inter_screen_data_t *d);

Y el fichero "title_screen.c":

void title_screen_on_load(struct inter_screen_data_t *d) {
    // deshabilitar renderizado
    // inicializar esta pantalla
    // configurar PPU (baldosas, paletas, sprites, etc.) para esta pantallaines);
    // habilitar renderizado
}

const struct screen_t *title_screen_on_vblank(uint8_t controller_mask, struct inter_screen_data_t *d) {
    // rutina porincipal de vblank
    // devolver NULL para permanecer en esta pantalla
    // devolver puntero a otra screen si se quiere cambiar de pantalla
}

void title_screen_on_unload(struct inter_screen_data_t *d) {
    // código a ejecutar antes de irse de esta pantalla (puede estar vacío)
}

En "main.c" incluimos los ".h" de todas las pantallas y definimos el array "screens" como constante que guarda los punteros a todas las funciones definidas.

La ventaja de esta aproximación es que en cada ".c" nos podemos centrar en el problema de pintar, leer las entradas y gestionar esa pantalla en concreto, sin preocuparnos por las demás y añadir nodos o cambiar el comportamiento de algunos apenas requerirá cambiar "main.c".

La paleta de colores no se cambia en este juego: se define una sola vez y se escribe en la PPU en "main.c" antes de empezar el bucle principal del juego. A continuación se comentan algunas cosas de interés:

CHR-ROM

La CHR-ROM utilizada es la del juego Thwaite con licencia GPL a la que se le han realizado algunos retoques para incluir baldosas para el tablero de minas y para los sprites encargados de resaltar la posición en la que nos encontramos en cada momento.


Nótese que como cada baldosa son 8x8 pixels, si queremos que el sprite haga de "marco exterior" de la baldosa donde se encuentra en cada momento, debe medir 10x10 pixels, por lo que necesitamos 4 sprites (ocupar 16x16 pixels) para generar un sprite de 10x10.

Pantalla de título

Para indicar la cantidad de minas con las que queremos inicializar el tablero lo lógico es usar un byte para almacenar el valor. Sin embargo a la hora de mostrar un byte por pantalla en formato decimal (para 2 cifras) hay que hacer una división entre 10, siendo las decenas el cociente y el resto de la división las unidades. Algo que parece trivial para un procesador actual, pero que para el 6502 es todo un mundo. Lo que hace el SDCC es implementar un algoritmo de división por software que funciona bien pero consume muchos ciclos de reloj.

Teniendo en cuenta que con respecto a la cantidad de minas sólo se iban a realizar operaciones de incremento, decremento y visualizar, y que la cantidad máxima de minas sería 99, se optó por almacenar dicho valor en formato BCD: por ejemplo el valor 45 en decimal es 0x2D en hexadecimal y 0x45 en BCD. Haciéndolo de esta forma la visualización es muy rápida:

decenas = (valor >> 4) + '0'
unidades = (valor & 0x0F) + '0'

Y, aunque los incrementos y decrementos son algo más complejos, compensa no tener que hacer una división entera para visualizar el valor cada vez que cambia.

#define  BCD_INC(bcd_v)  ((((bcd_v) & 0x0F) == 0x09) ? (((bcd_v) + 0x10) & 0xF0) : ((bcd_v) + 1))
#define  BCD_DEC(bcd_v)  ((((bcd_v) & 0x0F) == 0x00) ? ((((bcd_v) - 0x10) & 0xF0) | 0x09) : ((bcd_v) - 1))


Pantalla de tablero

El tablero es de 16 x 16 = 256 baldosas y está centrado en la pantalla. Al tener esa cantidad de posiciones, el offset en el tablero puede ser definido con un solo byte (0x00 a 0xFF) y, además, el nibble menos significativo de ese byte es la coordenada X mientras que el nibble más significativo es la coordenada Y:

// obtener offset a partir de coordenadas
uint8_t offset = (y << 4) | x;
uint8_t valor = tablero[offset];

// obtener coordenadas a partir de offset
uint8_t x = offset & 0x0F;
uint8_t y = offset >> 4;

Cada posición del tablero es un byte con los siguientes valores:

- 0x0F: para indicar que en esa posición hay una mina

- 0x01 a 0x08: para indicar que alrededor hay 1 a 8 minas. Al descubrirse esta baldosa aparece el número correspondiente.

0x00: para indicar que alrededor hay 0 minas. Igual que el anterior caso con la diferencia de que en este caso se muestra un hueco negro (no se muestra un "0").

Y las siguientes máscaras:

- 0x80: es una máscara que indica si esa posición está "pisada" o "descubierta". Por ejemplo una posición con el valor 0x84 es una posición descubierta que tiene alrededor 4 minas (y por tanto visualiza el número 4). El valor 0x8F nunca se va a dar pues en cuanto se pisa una mina termina en juego con "game over". Para pisar una posición presionamos el botón A del mando de la NES.

- 0x40: es una máscara que indica si en esa posición hemos puesto una banderita para ayudarnos a localizar las posiciones donde sospechamos que hay minas. Las posiciones donde haya una bandera no puede ser pisadas ("descubiertas"). Para colocar o quitar una bandera presionamos el botón B del mando de la NES.

En caso de que se pise una posición con bomba y sin banderita se terminará el juego con "game over" mientras que si se descubren todas las posiciones que no tengan minas y quedan cubiertas sólo las posiciones que tienen minas (tengan banderita o no), se terminará el juego con un "you win!".

La pantalla de "game over" o "you win!" permanece unos segundos antes de regresar a la pantalla de título de nuevo. A continuación puede verse el juego cargado en un emulador:





El marco azul que se va moviendo son 4 sprites dispuestos en cuadrado (2x2 sprites, 16x16 pixels) asociados a las baldosas 0x93, 0x94, 0xA3 y 0xA4. Esas baldosas juntas crean la imagen de un marco de 10x10 pixels con el centro de 8x8 pixels transparente y son los 4 sprites que se van moviendo de forma solidaria al pulsar los botones de la cruceta.

A continuación puede verse de forma resumida cómo se definen los sprites y cómo se mueven utilizando la técnica de transferencia DMA:

struct sprite_t {
    uint8_t y;
    uint8_t tile;
    uint8_t attr;
    uint8_t x;
};


// sprites al final de la RAM
#define  SPRITES   ((struct sprite_t *) 0x0700)


// En el on_load situamos los 4 sprites
// en la esquina superior izquierda del tablero
    SPRITES[0].x = 63;                 // top left sprite
    SPRITES[0].y = 54;
    SPRITES[0].tile = 0x93;
    SPRITES[0].attr = 0x00;
    SPRITES[1].x = 71;                 // top right sprite
    SPRITES[1].y = 54;
    SPRITES[1].tile = 0x94;
    SPRITES[1].attr = 0x00;
    SPRITES[2].x = 63;                 // bottom left sprite
    SPRITES[2].y = 62;
    SPRITES[2].tile = 0xA3;
    SPRITES[2].attr = 0x00;
    SPRITES[3].x = 71;                 // bottom right sprite
    SPRITES[3].y = 62;
    SPRITES[3].tile = 0xA4;
    SPRITES[3].attr = 0x00;
    OAMADDR = 0;          // transferencia DMA
    OAMDMA = 0x07;


// Para moverlos una posición del tablero
// a la derecha en on_vblank bastaría con hacer
    SPRITES[0].x += 8;
    SPRITES[1].x += 8;
    SPRITES[2].x += 8;
    SPRITES[3].x += 8;
    OAMADDR = 0;
    OAMDMA = 0x07;     // transferencia DMA


Así es cómo se ve el juego en una NES real:





Todo el código está disponible en la sección soft.

[ añadir comentario ] ( 116 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 193 )
Programación de la Sega Mega Drive 
Tras estudiar, en la anterior entrega, el desarrollo en la consola Sega Master System, le toca el turno ahora a la Sega Mega Drive. La consola de cuarta generación de Sega salió entre 1988 y 1990 y fue la primera consola de 16 bits de Sega, compitiendo con la SNES de Nintendo. La Mega Drive (conocida en EEUU como Sega Genesis) incluye un procesador Motorola 68000 (bus de 16 bits y registros de 32 bits) y un coprocesador Z80 dedicado para el sonido. El VDP es mucho más potente que su predecesora, la Master System (más colores, scroll vertical y horizontal, varios planos, mayor cantidad de sprites, etc.), e incluye, además del PSG, un chip de sonido con síntesis FM y un canal de sonido muestreado.

Breve repaso a la arquitectura de la Sega Mega Drive

La Sega Mega Drive tiene, como procesador principal, un 68000 de Motorola (la consola disponen también de un procesador Z80 auxiliar destinado al procesamiento de sonido que se verá más adelante). El 68000 dispone de un bus de direcciones de 24 bits, que le permite direccionar un total de 16 Mbytes de memoria distribuidos de la siguiente manera.

Mapa de memoria

InicioFinDescripción
0x0000000x3FFFFFROM y RAM del cartucho (4 Mbytes)
0x4000000x9FFFFFEspacio reservado para Mega-CD y 32X
0xA000000xA0FFFFRAM: Espacio de memoria del Z80 (YM2612 y PSG)
0xA100000xBFFFFFE/S y reservado
0xC000000xC0000FVDP
0xFF00000xFFFFFFRAM de trabajo del 68000 (64 Kbytes)


Esta tabla es una simplificación para facilitar la comprensión del tema. Una versión más exhaustiva del mapa de memoria puede encontrarse aquí.

El espacio de memoria que está entre 0xA00000 y 0xA0FFFF (desde el punto de vista del 68000) se corresponde con todo el espacio de memoria del procesador Z80 (0x0000 a 0xFFFF). Es una memoria RAM compartida entre ambos procesadores pero no accesible de forma simultánea por ambos. Cuando veamos la parte de la interacción entre el 68000 y el coprocesador Z80 se explicará este tema con más detalle.

Interrupciones

El 68000 dispone de múltiples fuentes de interrupción, pero en el caso de la Sega Mega Drive, la cantidad de fuentes de interrupción se limita a 3:

- Interrupción externa: provocada por accesorios externos (pistola óptica, por ejemplo).

- Interrupción de H Blank: generada cuando se pinta una línea concreta de la pantalla (configurable en los registros del VDP).

- Interrupción de V Blank: generada en cada retrazo vertical (después de que se pinte cada frame de pantalla).

Mapa de memoria del cartucho

Los 4 Mbytes de la ROM y la RAM del cartucho se disfribuyen de la siguiente manera:

InicioTamañoDescripción
0x0000004 bytesPuntero de pila (A7) en el arranque
0x0000044 bytesVector de reset (donde empieza la ejecución)
0x0000684 bytesPuntero a rutina de interrupción externa
0x0000704 bytesPuntero a rutina de interrupción H Blank
0x0000784 bytesPuntero a rutina de interrupción V Blank
0x000100256 bytesCabecera del cartucho (nombre, checksum, etc.)
0x0002002 Mbytes - 512 bytesResto del código del cartucho
0x20000064 KbytesSRAM del cartucho (opcional, para guardar partidas)
0x2100002 MBytes - 64 KBytesContinúa la ROM del cartucho


La localización y el tamaño de la RAM del cartucho es arbitraria y puede alojarse en cualquier parte entre 0x000000 y 0x3FFFFF. Es en la cabecera del cartucho (0x000100) donde se indica dónde se encuentra, en caso de haberla, la SRAM del cartucho para almacenar las partidas guardadas.

En la cabecera (los 256 bytes alojados en 0x000100) se indica el nombre del juego, fabricante, distribuidor, las zonas compatibles (Japón, EE.UU., Europa, etc.), mandos compatibles, checksum, etc.

Compilar la toolchain para Motorola 68000

La toolchain estándar de GNU (binutils + gcc) junto con la newlib pueden ser compilados para el target "m68k-none-elf" siguiendo los pasos estándar:

binutils

mkdir -p /opt/baremetalm68k/src
cd /opt/baremetalm68k/src
wget https://ftp.gnu.org/gnu/binutils/binutils-2.44.tar.xz
tar xf binutils-2.44.tar.xz
mkdir -p /opt/baremetalm68k/build/binutils-2.44
cd /opt/baremetalm68k/build/binutils-2.44
../../src/binutils-2.44/configure --prefix=/opt/baremetalm68k --target=m68k-none-elf --disable-nls
make
make install


gcc (fase 1)

cd /opt/baremetalm68k/src
wget https://ftp.gnu.org/gnu/gcc/gcc-15.1.0/gcc-15.1.0.tar.xz
wget https://ftp.gnu.org/gnu/gmp/gmp-6.3.0.tar.xz
wget https://ftp.gnu.org/gnu/mpc/mpc-1.3.1.tar.gz
wget https://ftp.gnu.org/gnu/mpfr/mpfr-4.2.2.tar.xz
tar xf gcc-15.1.0.tar.xz
tar xf gmp-6.3.0.tar.xz
tar xf mpc-1.3.1.tar.gz
tar xf mpfr-4.2.2.tar.xz
mv gmp-6.3.0 gcc-15.1.0/gmp
mv mpc-1.3.1 gcc-15.1.0/mpc
mv mpfr-4.2.2 gcc-15.1.0/mpfr
mkdir -p /opt/baremetalm68k/build/gcc-15.1.0-stage-1
cd /opt/baremetalm68k/build/gcc-15.1.0-stage-1
export PATH=/opt/baremetalm68k/bin:${PATH}
../../src/gcc-15.1.0/configure --prefix=/opt/baremetalm68k --target=m68k-none-elf --enable-languages=c --without-headers --disable-nls --disable-threads --disable-shared --disable-libssp --with-newlib
make all-gcc all-target-libgcc
make install-gcc install-target-libgcc


newlib

cd /opt/baremetalm68k/src
git clone https://sourceware.org/git/newlib-cygwin.git
mkdir -p /opt/baremetalm68k/build/newlib
cd /opt/baremetalm68k/build/newlib
../../src/newlib-cygwin/configure --prefix=/opt/baremetalm68k --target=m68k-none-elf
make
make install


En la última versión de newlib utilizada en el momento de realizar este proyecto se produjeron algunos errores de compilación derivados de la falta de definición de varias de las primitivas ("stat", "open", etc.). Como no haremos uso de dichas características de la newlib, me limité a crear "stubs" (funciones vacías) para dichas primitivas, con lo que se consigue compilar la newlib y, a su vez, poder compilar también la STL para C++. De todas formas en los proyectos realizados no se hace uso de la STL.

gcc (fase 2)

mkdir -p /opt/baremetalm68k/build/gcc-15.1.0-stage-2
cd /opt/baremetalm68k/build/gcc-15.1.0-stage-2
../../src/gcc-15.1.0/configure --prefix=/opt/baremetalm68k --target=m68k-none-elf --enable-languages=c,c++ --disable-nls --disable-threads --disable-shared --disable-libssp --with-newlib --with-headers=../../src/newlib-cygwin/newlib/libc/include
make
make install


Ahora ya tenemos en /opt/baremetalm68k/bin el GCC para el target 68000.

Generación de código y linker script

Teniendo el cuenta el mapa de memoria de la Mega Drive, puede hacerse un linker script que incluya las secciones de interrupciones (externa, H Blank y V Blank), así como la cabecera que permite identificar la ROM como una ROM de Sega Mega Drive.

SECTIONS { . = 0x000000 ; .vectors : { LONG(0x01000000); /* stack pointer at end or work RAM */ LONG(0x00000200); /* reset vector at 0x200 */ } . = 0x000068 ; .level2_autovector : { LONG(EXT_ISR_ADDRESS); } . = 0x000070 ; .level4_autovector : { LONG(H_BLANK_ISR_ADDRESS); } . = 0x000078 ; .level6_autovector : { LONG(V_BLANK_ISR_ADDRESS); } . = 0x000100 ; .header : { KEEP(header.o (.header)) } . = 0x000200 ; .text : { /* all the code here */ _linker_code = . ; KEEP(startup.o (.startup)) KEEP(*(.text)) KEEP(*(.text.*)) KEEP(*(.gnu.linkonce.t*)) KEEP(*(.gnu.linkonce.r*)) KEEP(*(.rodata*)) KEEP(*(.got*)) } . = ALIGN(4); EXT_ISR_ADDRESS = . ; .ext_isr : { KEEP(*(.ext_isr)) } . = ALIGN(4); H_BLANK_ISR_ADDRESS = . ; .h_blank_isr : { KEEP(*(.h_blank_isr)) } . = ALIGN(4); V_BLANK_ISR_ADDRESS = . ; .v_blank_isr : { KEEP(*(.v_blank_isr)) } . = ALIGN(4); .preinit_array : { __preinit_array_start = . ; KEEP(*(.preinit_array)) __preinit_array_end = . ; } . = ALIGN(4); .init_array : { __init_array_start = . ; KEEP(*(.init_array)) __init_array_end = . ; } . = ALIGN(4); .fini_array : { __fini_array_start = . ; KEEP(*(.fini_array)) __fini_array_end = . ; } . = ALIGN(4); .ctors : { __CTOR_LIST__ = . ; LONG((__CTOR_END__ - __CTOR_LIST__) / 2 - 2) KEEP(*(.ctors)) LONG(0) __CTOR_END__ = . ; } . = ALIGN(4); .dtors : { __DTOR_LIST__ = . ; LONG((__DTOR_END__ - __DTOR_LIST__) / 2 - 2) KEEP(*(.dtors)) LONG(0) __DTOR_END__ = . ; } . = 0x1F8000 ; /* sound loop right before save ram */ .sound_loop : { KEEP(*(.sound_loop)) } _flash_sdata = . ; . = 0xFF0000 ; /* work RAM starting at 0xFF0000 */ _ram_sdata = . ; .data : AT (LOADADDR(.dtors) + SIZEOF(.dtors)) { _linker_data = . ; *(.data) *(.data.*) *(.sdata) *(.sdata.*) *(.gnu.linkonce.d*) } _ram_edata = . ; _ram_sbssdata = . ; .bss : AT (LOADADDR(.data) + SIZEOF(.data)) { _linker_bss = . ; *(.bss) *(.bss.*) *(.sbss) *(.sbss.*) *(.gnu.linkonce.b.*) *(.COMMON) } _ram_ebssdata = . ; _linker_end = . ; end = . ; }


Como se puede ver, toda la ROM se aloja en los primeros 4Mb del cartucho y la RAM en los 64Kb de Work RAM. El puntero de pila se inicializa al final de la Work RAM y el puntero de reset apunta a justo después de la cabecera del cartucho.

Nótese que el puntero de pila se inicializa a 0x010000. La pila en el 68000 (y en la mayoría de las arquitecturas) crece hacia direcciones bajas de memoria, además, la operación de PUSH realiza un pre-decremento del puntero de pila, mientras que la operación POP realiza un post-incremento del puntero de pila. De esta forma la "pila vacía" la implementamos haciendo que el puntero de pila SP (A7) apunte a 0x010000, una dirección inexistente pero que, cuando se pre-decremente (PUSH) sí apuntará una dirección de memoria válida. De esta manera aprovechamos el 100% de la memoria RAM de trabajo que no es muy extensa (sólo tenemos 64 KBytes para las variables globales y para la pila del sistema).

Prueba de concepto

Como primera prueba generaremos la ROM de un cartucho que simplemente inicializará el VPD y la VRAM para poner una imagen estática en pantalla. El VDP de la Mega Drive dispone de varios planos destinados a hacer scroll y efectos (plano B, plano A, Plano Window y los sprites). Cada plano, a su vez tiene un bit de prioridad que permite jugar con las superposiciones de pixels y los efectos.

No me pararé a destallar de forma pormenorizada los detalles de VDP en este sentido, remito al lector a la wiki de Railgun para más información.

El VDP de la Mega Drive, al igual que casitodos los VDPs de la época (tanto de consolas como de ordenadores como el MSX, el Spectrum, etc.) está basado en el concepto de baldosas o "tiles": en una zona de la VRAM se definen las baldosas como agrupaciones de 8x8 pixels (en el caso de la megadrive, cada baldosa ocupa 32 bytes, ya que cada pixel son 4 bits, 16 colores, cada fila de pixels en la baldosa son 4 bytes), mientras que en otra zona de la VRAM se definen los planos, siendo cada plano un conjunto de MxN índices de baldosa (en terminología VDP se suelen llamas las "name tables"). No existe el concepto de "frame buffer" y todo debe ser dibujado a base de baldosas.



(Imagen extraida de un tileset gratuito publicado por al autor Vexed en itch.io)

using namespace std;

void ext_isr()  __attribute__((interrupt, section(".ext_isr")));

void ext_isr() {
    //
}

void h_blank_isr()  __attribute__((interrupt, section(".h_blank_isr")));

void h_blank_isr() {
    //
}

void v_blank_isr()  __attribute__((interrupt, section(".v_blank_isr")));

void v_blank_isr() {
    //
}

#define  W_VDP_DATA        *((uint16_t *) 0xC00000)
#define  W_VDP_CTRL        *((uint16_t *) 0xC00004)
#define  W_VDP_HV_COUNTER  *((uint16_t *) 0xC00008)
#define  L_VDP_DATA        *((uint32_t *) 0xC00000)
#define  L_VDP_CTRL        *((uint32_t *) 0xC00004)

#define  VDPREG_MODE1      0x8000  // Mode register #1
#define  VDPREG_MODE2      0x8100  // Mode register #2
#define  VDPREG_PLANEA     0x8200  // Plane A table address
#define  VDPREG_WINDOW     0x8300  // Window table address
#define  VDPREG_PLANEB     0x8400  // Plane B table address
#define  VDPREG_SPRITE     0x8500  // Sprite table address
#define  VDPREG_BGCOL      0x8700  // Background color
#define  VDPREG_HRATE      0x8A00  // HBlank interrupt rate
#define  VDPREG_MODE3      0x8B00  // Mode register #3
#define  VDPREG_MODE4      0x8C00  // Mode register #4
#define  VDPREG_HSCROLL    0x8D00  // HScroll table address
#define  VDPREG_INCR       0x8F00  // Autoincrement
#define  VDPREG_SIZE       0x9000  // Plane A and B size
#define  VDPREG_WINX       0x9100  // Window X split position
#define  VDPREG_WINY       0x9200  // Window Y split position
#define  VDPREG_DMALEN_L   0x9300  // DMA length (low)
#define  VDPREG_DMALEN_H   0x9400  // DMA length (high)
#define  VDPREG_DMASRC_L   0x9500  // DMA source (low)
#define  VDPREG_DMASRC_M   0x9600  // DMA source (mid)
#define  VDPREG_DMASRC_H   0x9700  // DMA source (high)

/*
screen width : 40 tiles * 8 = 320
screen height: 28 tiles * 8 = 224

Plane A table          : 0xC000
Window name table      : 0xD000
Plane B table          : 0xE000
Sprite attribute table : 0xF000
H scroll data          : 0xF400

 */
void vdp_init() {
    W_VDP_CTRL = VDPREG_MODE1 | 0x04;    // mode register #1: display disabled
    W_VDP_CTRL = VDPREG_MODE2 | 0x04;    // mode register #2: display disabled, v blank interrupt disabled, mode 5 video mode, 28 tiles height
    W_VDP_CTRL = VDPREG_PLANEA | 0x30;   // plane A address: upper 3 bits of VRAM location of plane A name table = 0xC000
    W_VDP_CTRL = VDPREG_WINDOW | 0x34;   // window address: upper 5 bits of VRAM location of window name table = 0xD000
    W_VDP_CTRL = VDPREG_PLANEB | 0x07;   // plane B address: upper 3 bits of VRAM location of plane B name table = 0xE000
    W_VDP_CTRL = VDPREG_SPRITE | 0x78;   // sprite address: upper 7 bits of VRAM location of sprite attribute table = 0xF000
    W_VDP_CTRL = VDPREG_BGCOL | 0x00;    // background color: palette 0, index 0
    W_VDP_CTRL = VDPREG_HRATE | 0xFF;    // h blank irq rate: horizontal line at wich the h blank interrupt will be triggered
    W_VDP_CTRL = VDPREG_MODE3 | 0x00;    // mode register #3: external interrupt disabled, entire panel vertical scroll, entire panel horizontal scroll
    W_VDP_CTRL = VDPREG_MODE4 | 0x81;    // mode register #4: 40 tiles width, no interlaced mode
    W_VDP_CTRL = VDPREG_HSCROLL | 0x3D;  // H scroll address: upper 6 bits of VRAM location of horizontal scroll table = 0xF400
    W_VDP_CTRL = VDPREG_INCR | 0x02;     // autoincrement: 2 (each VRAM data write will autoincrement VRAM pointer by 2, one 16 bit word at once)
    W_VDP_CTRL = VDPREG_SIZE | 0x00;     // tilemap size: 32x32 tiles for both A and B planes
    W_VDP_CTRL = VDPREG_WINX | 0x00;     // window x split: window plane x = 0
    W_VDP_CTRL = VDPREG_WINY | 0x00;     // window y split: window plane y = 0
    // clear vram
    L_VDP_CTRL = 0x40000000;
    uint16_t n = 65536 >> 1;
    while (n > 0) {
        W_VDP_DATA = 0;
        n--;
    }
    // clear cram
    L_VDP_CTRL = 0xC0000000;
    n = 128 >> 1;
    while (n > 0) {
        W_VDP_DATA = 0;
        n--;
    }
    // clear vsram
    L_VDP_CTRL = 0x40000010;
    n = 80 >> 1;
    while (n > 0) {
        W_VDP_DATA = 0;
        n--;
    }
}

void vdp_vram_write(const uint16_t *src, uint16_t size_bytes, uint16_t vram_addr) {
    uint16_t test = W_VDP_CTRL;          // prepare vdp
    L_VDP_CTRL = 0x40000000 | ((((uint32_t) vram_addr) & 0x3FFF) << 16) | (((uint32_t) vram_addr) >> 14);
    size_bytes >>= 1;
    while (size_bytes > 0) {
        W_VDP_DATA = *src;
        size_bytes--;
        src++;
    }
}

void vdp_cram_write(const uint16_t *src, uint16_t size_bytes, uint16_t cram_addr) {
    uint16_t test = W_VDP_CTRL;          // prepare vdp
    L_VDP_CTRL = 0xC0000000 | ((((uint32_t) cram_addr) & 0x3FFF) << 16) | (((uint32_t) cram_addr) >> 14);
    size_bytes >>= 1;
    while (size_bytes > 0) {
        W_VDP_DATA = *src;
        size_bytes--;
        src++;
    }
}

void vdp_vsram_write(const uint16_t *src, uint16_t size_bytes, uint16_t vsram_addr) {
    uint16_t test = W_VDP_CTRL;          // prepare vdp
    L_VDP_CTRL = 0x40000010 | ((((uint32_t) vsram_addr) & 0x3FFF) << 16) | (((uint32_t) vsram_addr) >> 14);
    size_bytes >>= 1;
    while (size_bytes > 0) {
        W_VDP_DATA = *src;
        size_bytes--;
        src++;
    }
}

void vdp_enable(bool v) {
    uint16_t test = W_VDP_CTRL;          // prepare vdp
    if (v) {
        W_VDP_CTRL = VDPREG_MODE1 | 0x05;    // mode register #1: display enabled
        W_VDP_CTRL = VDPREG_MODE2 | 0x64;    // mode register #2: display enabled, v blank interrupt enabled, mode 5 video mode
    }
    else {
        W_VDP_CTRL = VDPREG_MODE1 | 0x04;    // mode register #1: display enabled
        W_VDP_CTRL = VDPREG_MODE2 | 0x04;    // mode register #2: display disabled, v blank interrupt disabled, mode 5 video mode
    }
}

void vdp_vram_write_tile_indices() {
    uint16_t test = W_VDP_CTRL;          // prepare vdp
    uint16_t vram_addr = 0xE000;         // draw tiles on plane b with low priority
    L_VDP_CTRL = 0x40000000 | ((((uint32_t) vram_addr) & 0x3FFF) << 16) | (((uint32_t) vram_addr) >> 14);
    uint16_t i = 1;     // put tiles 1, 2, 3, 4, 5, 6, 7, 8... 1023 and 1024 (tile 0 is full transparent)
    int16_t n = 32 * 32;
    while (n > 0) {
        W_VDP_DATA = i;
        n--;
        i++;
    }
}

#include "ghg.h"     // include "s_tile" constant with all 1024 (32 * 32) tiles

int main() {
    vdp_init();
    vdp_cram_write(s_palette, 32, 0);   // palette 0
    vdp_vram_write((const uint16_t *) s_tiles, 32 * 32 * 8 * 4, 32);      // 1st tile at tile index 1 in VRAM (keep tile 0 full transparent)
    vdp_vram_write_tile_indices();
    vdp_enable(true);
    asm volatile ("move.w #0x2300, %sr");    // enable interrupts
    while (true)
        ;
    return 0;
}


Los pasos principales son los siguientes:

- Se inicializa el VDP: resolución de 320x224 pixels (40x28 baldosas) y planos A y B de 32x32 baldosas. Cuando se pinta un plano que es más chico que la pantalla, el pintado "da la vuelta" y vuelve a empezar en cada plano, por lo que se ve como un mosaico en pantalla. La inicialización también llena de ceros la VRAM. La "name table" del plano B se configura para estar en la dirección 0xE000 de la VRAM.

- Se inicializa la paleta: se aloja en una zona de memoria especial del VDP llamada CRAM (Color RAM).

- Se escribe en la VRAM las baldosas: en nuestro caso se precalculan las baldosas a partir de una imagen exportada desde el GIMP (ver código "ghg_256_256.cpp").

- Se escribe en la VRAM la table name: en nuestro caso, como es una imagen estática, se genera simplemente la secuencia 1, 2, 3, 4, 5... etc, hasta llegar a la cantidad de baldosas que se quieren poner (32x32 = 1024). La baldosa con índice 0 se deja intacta con todos los pixels a 0 (transparentes), puesto que la rutina de inicialización de la VRAM deja toda la VRAM a 0.

- Se habilitan las interrupciones: "move.w #0x2300, %sr"

- Nos quedamos en un bucle infinito: Por ahora las rutinas de interrupción externa, H blank y V blank se dejan vacías.



Uso de scroll y sprites

Los planos A y B pueden hacer scroll tanto horizontal como vertical de forma independiente escribiendo en unas zonas de memoria concretas de la VRAM y de la VSRAM. Sin apenas cambiar el código anterior podemos genera un efecto de scroll en el plano B simplemente actualizando el offset de scroll horizontal y vertical en cada interrupción de retrazo vertical (V Blank).

En nuestro caso generamos una lista de offsets verticales y horizontales fija mediante un script:

- Con el script "generate_circ_coords.sh" generamos un .h con un array de offsets que sigen un trayectoria circular.

- En "main.cpp" incluimos dicho .h

- En cada interrupción de V Blank obtenemos un offset de scroll horizontal (que escribimos en la posición adecuada de la VRAM), un offset de scroll vertical (que escribimos en la posición adecuada de la VSRAM) y avanzamos en el array de offsets para que en el siguiente V Blank se cambie el scroll a un nuevo valor. Cuando se llega al final de la lista de offsets se empieza por el principio de nuevo.

#include "circular_scroll_offsets.h"    // precalculated circular scroll offsets

uint16_t next_circular_scroll_offset;

void v_blank_isr()  __attribute__((interrupt, section(".v_blank_isr")));

void v_blank_isr() {
    // update VRAM and VSRAM
    vdp_vram_write((const uint16_t *) (s_circular_scroll_offsets + next_circular_scroll_offset),      2, 0xF402);   // update plane B (background) horizontal scroll
    vdp_vsram_write((const uint16_t *) (s_circular_scroll_offsets + next_circular_scroll_offset + 1), 2, 2);        // update plane B (background) vertical scroll
    .
    .
    .
    // update scroll offsets for next frame
    next_circular_scroll_offset += 2;
    if (next_circular_scroll_offset == (s_circular_scroll_offsets_steps << 1))
        next_circular_scroll_offset = 0;
}


Por otro lado definimos un sprite sencillo mediante una baldosa de 8x8 pixels:

const uint32_t s_sprite_tile[] __attribute__((aligned(2))) = {
    0xFFFFFFFF,
    0xF000000F,
    0xF0F00F0F,
    0xF000000F,
    0xF0F00F0F,
    0xF00FF00F,
    0xF000000F,
    0xFFFFFFFF
};


Que metemos al final de la lista de baldosas:

int main() {
    .
    .
    .
    // sprite tile at tile index 1121 in VRAM
    vdp_vram_write((const uint16_t *) s_sprite_tile, 32, 32 + (32 * 32 * 8 * 4));
    .
    .
    .
}


Definimos la estructura "sprite_attribute_t" que rellenamos para hacer referencia al sprite recién definido:

struct sprite_attribute_t {
    uint16_t y;
    uint16_t size_and_link_data;
    uint16_t pattern;
    uint16_t x;
}  __attribute__((packed));

sprite_attribute_t sa  __attribute__((aligned(2)));
int16_t x;
int16_t y;
int16_t dx;
int16_t dy

.
.
.

int main() {
    .
    .
    .
    sa.x = 128;
    sa.y = 128;
    x = y = 0;
    dx = dy = 1;
    sa.size_and_link_data = 0;
    // priority = 0, palette = 0, no vertical flip, no horizontal flip,
    // pattern = 1025 (pattern 0 = transparent, patterns 1...1024 = plane B)
    sa.pattern = 1025;
    .
    .
    .
}


Y actualizamos sus coordenadas en cada V Blank.

void v_blank_isr() {
    .
    .
    .
    // first sprite in the sprite attribute table
    // sprite = 8 bytes, sizeof(sprite_attribute_t)
    vdp_vram_write((const uint16_t *) &sa, 8, 0xF000);
    // update sprite position for next frame
    x += dx;
    if (x >= (320 - 8))
        dx = -1;
    else if (x <= 0)
        dx = 1;
    y += dy;
    if (y >= (224 - 8))
        dy = -1;
    else if (y <= 0)
        dy = 1;
    sa.x = (128 + x) & 0x01FF;
    sa.y = (128 + y) & 0x03FF;
    .
    .
    .
}


Con muy poco código pueden hacerse cosas muy elaboradas:



Uso del Z80 como coprocesador

El Z80 que incluye la Sega Mega Drive tiene dos funcionalidades: actuar como coprocesador para sonido del 68000 en modo Mega Drive o actuar como procesador principal cuando la consola se utiliza en modo de compatibilidad con Master System (se utiliza un accesorio especial que permite insertar cartuchos de SMS en la Mega Drive). En nuestro caso usaremos el Z80 como coprocesador de sonido y liberar al 68000 de ese trabajo.

La memoria del Z80, entre 0x0000 y 0xFFFF se corresponde con las direcciones 0xA00000 y 0xA0FFFF del 68000:

InicioFinDescripción
0x00000x1FFFRAM principal (8 Kbytes)
0x20000x3FFFreservado
0x40000x4003YM2612 (sonido FM y PCM)
0x40040x5FFFreservado
0x60000x6000registro de banco
0x60010x7F10reservado
0x7F110x7F11SN76489 (sonido PSG)
0x7F120x7FFFreservado
0x80000xFFFFbanco ROM 68000


Como se puede ver, este coprocesador está destinado más bien para controlar el sonido, puesto que tanto el YM2612 como el PSG se encuentran en su espacio de direcciones. En el Z80 se dispara también la interrupción V Blank (a la par que en el 68000).

La zona de memoria entre 0x8000 y 0xFFFF (32 Kbytes) accesible desde el Z80 permite leer desde el Z80 cualquier zona de la ROM del cartucho de la Mega Drive sin intervención del 68000. En la dirección 0x6000, el Z80 escribe (de una forma particular) los 9 bits más significativos del bus de direcciones del 68000 (del A15 al A23) y a continuación el Z80 puede acceder en modo de solo lectura, al banco seleccionado entre 0x8000 y 0xFFFF (32 KBytes).

Iniciar el coprocesador Z80

Para arrancar el coprocesador Z80 de la Mega Drive, lo primero que hay que hacer es copiar el código Z80 que queremos que ejecute en los primeros 8 Kbytes de 0xA00000. Es necesario poner los buses del Z80 en alta impedancia realizando una petición de bus ("bus request") ya que esta zona de la memoria, aunque sea compartida entre el 68000 y el Z80, ambos procesadores no pueden estar accediendo a ella de forma simultánea. En este caso concreto lo que se hace es:

1. Activar el reset del Z80

2. Activar el pin BUSREQ del Z80

3. Liberar el reset del Z80

A partir de ese momento la memoria desde 0xA00000 a 0xA02000 (8 KBytes) es totalmente accesible desde el 68000 (el Z80 ha puesto sus pines de bus en alta impedancia). Dicha memoria debe ser accedida byte a byte (no deben realizarse accesos de 16 ni de 32 bits) por parte del 68000. Lo habitual es este paso es que el código del Z80 se transfiera desde algún tipo de array constante en la zona de memoria de la ROM del cartucho hacia 0xA00000.

Cuando todo el programa del Z80 está cargado se libera el estado de reset del Z80 y se libera el bus por parte del 68000. En ese momento el Z80 empieza a funcionar de forma independiente del 68000 (la zona de memoria desde 0xA00000 a 0xA0FFFF ahora "es del Z80") ejecutando el programa recién transferido (el vector del reset del Z80 es 0x0000, es decir la dirección 0xA00000 desde el punto de vista del 68000).

Comunicación entre el 68000 y el Z80

La única forma de compartir memoria RAM entre el 68000 y el Z80 es utilizando el procedimiento de petición de bus ("bus request") por parte del 68000:

1. Pedir el bus desde el 68000 ("bus request").

2. Esperar a que el Z80 termine de ejecutar la instrucción actual y que active la señal "bus ack".

3. En ese momento el 68000 es "dueño" del bus y puede acceder a cualquier sitio del espacio de direcciones del Z80 (0xA00000 a 0xA0FFFF).

4. El 68000 libera el bus para que el Z80 continue la ejecución por donde iba.

Esta forma de acceso provoca retrasos y ciclos de más en el Z80 que deben ser tenidos en cuenta a la hora de controlar los tiempos de acceso. El Z80 se encarga del sonido y no es desable que la música y los efectos de sonido sufran de retrasos, clicks o underruns.

Una forma alternativa de comunicación permite al Z80 acceder a una zona de la memoria ROM del cartucho del juego sin necesidad de realizar peticiones de bus.

1. El Z80 escribe de una forma particular en la dirección de memoria 0x6000 de su espacio de memoria para indicar los 9 bits más significativos del bus de direcciones del 68000 (líneas A15 a A23).

2. A partir de ese instante, en los 32 KBytes de memoria que van desde 0x8000 a 0xFFFF el Z80 accede al banco de memoria correspondiente en el cartucho ROM del juego (espacio de memoria del 68000).

Sólo se puede elegir una zona de memoria del cartucho que sea ROM, no es posible acceder a ningún tipo de RAM mediante este método. Por ejemplo, si desde el Z80 queremos acceder a la zona de memoria que va desde 0x1F8000 hasta 0x1FFFFF en el cartucho ROM (espacio de memoria del 68000), el Z80 debe indicar en 0x6000 que los bits A23 a A15 son 000111111 y a continuación accediendo a la zona de memoria que va desde 0x8000 a 0xFFFF, el Z80 accederá a los 32 Kbytes del cartucho ROM que van desde 0x1F8000 hasta 0x1FFFFF.

Esta forma de acceso es muy útil cuando el Z80 va a reproducir sonido muestreado o canciones en formato VGM directamente desde la ROM del cartucho, ya que no requiere utilizar el mecanismo de "bus request"/"bus ack" y tanto el 68000 como el Z80 pueden ir a máxima velocidad.

Compilar la toolchain para Z80

Desgraciadamente, para el Z80 no disponemos de toolchain GCC, por lo que no podemos programarlo en C++, pero sí disponemos de toolchain C gracias al proyecto SDCC. Basta con descargar el fuente desde http://sdcc.sourceforge.net y compilar:

cd /ruta/fuente/sdcc
./configure --prefix=/opt/sdcc --disable-pic14-port --disable-pic16-port
make
make install


De esta forma tendremos en /opt/sdcc una toolchain de C operativa para Z80 (y otros procesadores pequeños). El código 68000 podemos hacerlo en C o C++ con GCC mientras que el código Z80 tenemos que hacerlo en C con SDCC.

Todo junto

Crearemos un código para Z80 que lea un buffer de sonido muestreado desde un banco de memoria del cartucho ROM del juego y lo reproduzca de forma indefinida (en bucle). El sonido muestreado lo fijaremos en la posición de memoria 0x1F8000, ocupando como máximo 32 Kbytes (para no tener que estar haciendo conmutación entre bancos de memoria).

El sonido del bucle lo extraemos del banco de muestras libre de regalías samplefocus.com. En concreto descargamos este bucle (creado por ichikabreakcore) del cual extraemos sólo el primer compás. Dicha muestra se ajusta para ocupar 32 Kbytes y se le cambia en formato a mono y 8 bits sin signo por muestra (para que sea compatible directamente con el canal PCM del YM2612). El fichero .raw generado con las muestras en crudo en convertido a fichero ELF para ser enlazado dentro del código de la ROM del cartucho de la Mega Drive (en el linker script del 68000 se fuerza el contenido de este fichero para que sea alojado en la posición 0x1F8000 del cartucho).

SECTIONS { /* ... */ . = 0x1F8000 ; /* sound loop right before save ram */ .sound_loop : { KEEP(*(.sound_loop)) } /* ... */ }


A continuación puede verse cómo queda el código del Z80 (en C, compilable con SDCC) que accede al banco de memoria 0x1F8000 de la ROM del cartucho y reproduce en bucle los bytes de ese banco de memoria.

void vblankISR(void) __critical __interrupt(0) {
}

void nmISR(void) __critical __interrupt {
}

#define  YM2612_STATUS     *((volatile uint8_t *) 0x4000)
#define  YM2612_A0         *((volatile uint8_t *) 0x4000)
#define  YM2612_D0         *((volatile uint8_t *) 0x4001)
#define  YM2612_A1         *((volatile uint8_t *) 0x4002)
#define  YM2612_D1         *((volatile uint8_t *) 0x4003)
#define  M68K_BANK_SELECT  *((volatile uint8_t *) 0x6000)
#define  M68K_BANK         ((const volatile uint8_t *) 0x8000)

#ifndef  LOOP_SIZE
#error "LOOP_SIZE must be defined"
#endif

void ym2612_write(uint8_t address, uint8_t value) {
    while (YM2612_STATUS & 0x80)
        ;
    YM2612_A0 = address;
    YM2612_D0 = value;
}

uint8_t ym2612_read(uint8_t address) {
    while (YM2612_STATUS & 0x80)
        ;
    YM2612_A0 = address;
    return YM2612_D0;
}

void main(void) {
    __asm__ ("di");
    // sound loop at address 0x1F8000 on 68000 memory: A23...A15 = 0001 1111 1xxx xxxx xxxx xxxx
    M68K_BANK_SELECT = 1;   // A15
    M68K_BANK_SELECT = 1;
    M68K_BANK_SELECT = 1;
    M68K_BANK_SELECT = 1;
    M68K_BANK_SELECT = 1;
    M68K_BANK_SELECT = 1;
    M68K_BANK_SELECT = 0;
    M68K_BANK_SELECT = 0;
    M68K_BANK_SELECT = 0;   // A23
    // enable DAC on YM2612
    ym2612_write(0x2B, 0x80);
    const uint8_t *p = M68K_BANK;
    uint16_t n = LOOP_SIZE;
    while (1) {
        // write sample
        ym2612_write(0x2A, *p);
        // next sample
        p++;
        n--;
        if (n == 0) {
            p = M68K_BANK;
            n = LOOP_SIZE;
        }
    }
}


En la carpeta "z80" tenemos un Makefile que compila este código C para Z80 utilizando el compilador SDCC y genera, como salida un fichero "mdz80binary.h". Este fichero "mdz80binary.h" define un array de bytes constante con el código binario (compilado por SDCC) que queremos que ejecute el Z80.

const unsigned char s_z80_binary[] = {
  0xf3, 0xed, 0x56, 0xc3, 0x69, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc3, 0x00, 0x02, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc3, 0x03, 0x02, 0x31, 0x00, 0x20,
  0xcd, 0x75, 0x02, 0xfb, 0xcd, 0x24, 0x02, 0xc3, 0x73, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfb, 0xed, 0x4d, 0xed,
  0x45, 0x47, 0x4d, 0x3a, 0x00, 0x40, 0x07, 0x38, 0xfa, 0x21, 0x00, 0x40,
  0x70, 0x2e, 0x01, 0x71, 0xc9, 0x4f, 0x3a, 0x00, 0x40, 0x07, 0x38, 0xfa,
  0x21, 0x00, 0x40, 0x71, 0x3a, 0x01, 0x40, 0xc9, 0xf3, 0x21, 0x00, 0x60,
  0x36, 0x01, 0x36, 0x01, 0x36, 0x01, 0x36, 0x01, 0x36, 0x01, 0x36, 0x01,
  0x36, 0x00, 0x36, 0x00, 0x36, 0x00, 0x2e, 0x80, 0x3e, 0x2b, 0xcd, 0x05,
  0x02, 0x2e, 0xff, 0x3e, 0x24, 0xcd, 0x05, 0x02, 0x2e, 0x01, 0x3e, 0x25,
  0xcd, 0x05, 0x02, 0x2e, 0x10, 0x3e, 0x27, 0xcd, 0x05, 0x02, 0x21, 0x00,
  0x80, 0x01, 0x83, 0x6c, 0x7e, 0xe5, 0xc5, 0x6f, 0x3e, 0x2a, 0xcd, 0x05,
  0x02, 0xc1, 0xe1, 0x23, 0x0b, 0x78, 0xb1, 0x20, 0xef, 0x21, 0x00, 0x80,
  0x01, 0x83, 0x6c, 0x18, 0xe7, 0xc9
};
const unsigned int s_z80_binary_len = 630;


Ese .h (con el código binario del programa del Z80) es, a continuación, incluido por el main.cpp del 68000 (como un array de bytes constante) para ser compilado con el GCC (C++) para 68000:

#include "z80/mdz80binary.h"

#define  Z80RAM     ((uint8_t *) 0xA00000)
#define  Z80BUSREQ  *((uint16_t *) 0xA11100)
#define  Z80RESET   *((uint16_t *) 0xA11200)

void z80_init(const uint8_t *code, const uint16_t code_size) {
    // request z80 bus to load z80 code
    Z80RESET = 0;
    Z80BUSREQ = 0x0100;
    Z80RESET = 0x0100;
    // copy z80 code
    const uint8_t *src = code;
    uint8_t *dst = Z80RAM;
    uint16_t n = code_size;
    while (n > 0) {
        *dst = *src;
        src++;
        dst++;
        n--;
    }
    // release bus and reset z80
    Z80RESET = 0;
    for (int i = 0; i < 192; i++)
        ;
    Z80RESET = 0x0100;
    Z80BUSREQ = 0;
}


int main() {
    .
    .
    .
    // load and start running z80 code
    z80_init(s_z80_binary, s_z80_binary_len);
    .
    .
    .
}


De esta forma el 68000 se encarga de la parte principal de la gestión de los gráficos y el Z80 de la gestión del sonido de forma concurrente.

Todo el código puede descargarse de la sección soft.

[ añadir comentario ] ( 965 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 1092 )
Programación de la Sega Master System 
La consola Sega Master System fue una consola de videojuegos de finales de los 80, con un hardware muy de su época: basada en cartuchos, con una cpu Z80, procesador gráfico TMS9918 (derivado de los usados en MSX) y chip de sonido SN76489 (muy de su época: 3 canales de tonos con onda cuadrada más un canal de ruido). A lo largo de este post se desarrollará una pequeña prueba de concepto desde cero en C para genera la ROM de un juego compatible con Sega Master System.

Mapa de memoria y secuencia de arranque

El mapa de memoria de la SMS es muy sencillo, ya que las primeras posiciones de memoria (el vector de reset del Z80 es 0x0000) están en el cartucho, por lo que el cartucho de cada juego es responsable del arranque correcto de la CPU (vector de reset, puntero de pila e interrupciones).

$10000 -----------------------------------------------------------
       Paging registers
$FFFC -----------------------------------------------------------
      Mirror of RAM at $C000-$DFFF
$E000 -----------------------------------------------------------
      8k of on-board RAM (mirrored at $E000-$FFFF)
$C000 -----------------------------------------------------------
      16k ROM Page 2, or one of two pages of Cartridge RAM
$8000 -----------------------------------------------------------
      16k ROM Page 1
$4000 -----------------------------------------------------------
      15k ROM Page 0
$0400 -----------------------------------------------------------
      First 1k of ROM Bank 0, never paged out with rest of Page 0
$0000 -----------------------------------------------------------

Aunque la SMS permite paginación de memoria, para ROMs de hasta 32Kb no es necesario y no será tenido en cuenta para este ejemplo. Los puntos clave en una ROM de la SMS son los siguientes:

- Vector de reset en 0x0000 (aquí empieza la ejecución del código)

- En 0x0038 está el vector de interrupción enmascarable: Las interrupciones en la SMS se gestionan con el modo 1 de la CPU, por lo que todas las interrupciones enmascarables (las "normales") van al vector 0x0038. En la SMS se puede configurar el chip gráfico (VDP) para que genere interrupciones cuando se produce un retrazo vertical o cuando se alcance determinada línea horizontal en el pintado.

- En 0x0066 está el vector de interrupción no enmastarable: en el caso de la SMS la interrupción no enmascarable está conectada al botón "pause" de la carcasa de la consola.

- La RAM de la consola empieza en 0xC000 y termina en 0xDFF0.

- En nuestro caso particular, para guardar las partidas, lo habitual es que en cartucho disponga de una RAM no volátil propia a partir de la dirección 0x8000 que se puede mapear en el espacio de direcciones de la CPU configurando unos registros especiales.

- Información sobre el cartucho (su cabecera) se encuentra alojada a partir de la dirección 0x7FF0 de la ROM del cartucho y consta de 16 bytes que indican tipo de ROM, tamaño y una pequeña firma ASCII para identificarla como ROM de SMS.

Aunque formalmente, la RAM termina en 0xDFFF, como dicha RAM tiene un espejo en 0xE000-0xFFFB y las últimas direcciones de memoria 0xFFFC-0xFFFF se usan para el control de la paginación de memoria, se considera que la RAM "termina" antes, en 0xDFF0. Por tanto, como la pila del Z80 crece hacia direcciones bajas de memoria (SP disminuye cuando se hace PUSH y aumenta cuando se hace POP), lo habitual es inicializar el puntero de pila SP a 0xDFF0 y que el compilador coloque las variables globales a partir de 0xC000.

Para la prueba de concepto se utilizará el compilador SDCC que se puede instalar siguiendo las instrucciones indicadas en este post, y que viene acompañado de todos los compiladores y enlazadores necesarios. A continuación se puede ver un típico esqueleto de código de arranque de ROM de Sega Master System en ensamblador:

    .module crt0sms
    .globl _main
    .globl _vblankISR
    .globl _nmISR
    .area _HEADER (ABS)

    .org 0x0000

    di
    im 1                            ; SMS runs in z80 interrupt mode 1
    jp init

    .org 0x0038

    jp _vblankISR

    .org 0x0066

    jp _nmISR

init:
    ; init stack pointer
    ld sp, #0xDFF0                  ; at end of onboard RAM
    ; init memory mapper
    ld hl,#0x0008                   ; first page of cartridge RAM at 0x8000
    ld (#0xfffc),hl                 ; [0xfffc] = 0x08, [0xfffd] = 0x00
    ld hl,#0x0201
    ld (#0xfffe),hl                 ; [0xfffe] = 0x01, [0xffff] = 0x02
    ; init global variables
    call gsinit
    ; main function
    ei
    call _main
inf_loop:
    jp inf_loop

    ; ordering of segments for the linker.
    .area   _HOME
    .area   _CODE
    .area   _INITIALIZER
    .area   _GSINIT
    .area   _GSFINAL

    .area   _CODE
    .area   _GSINIT
gsinit::
    .area   _GSFINAL
    ret

    .area   _DATA
    .area   _INITIALIZED
    .area   _BSEG
    .area   _BSS
    .area   _HEAP

    .area _TAIL (ABS)

    .org 0x7FF0               ; cartridge "header"

    .ascii "TMR SEGA"         ; https://www.smspower.org/Development/ROMHeader
    .db #0x00, #0x00          ; reserved
    .db #0x00, #0x00          ; checksum
    .db #0x26, #0x70, #0xA0   ; product code 107026, version 0
    .db #0x4C                 ; SMS export, rom size = 32 Kb

Los pasos para la inicialización son los siguientes:

- Deshabilitar interrupciones

- Poner el modo 1 de interrupciones (vector único en 0x0038).

- Poner el puntero de pila en 0xDFF0

- Configuramos en los registros de paginación (0xFFFC y 0xFFFE) que los primeros 8K de la RAM del cartucho (para guardar partidas) estarán mapeados a partir de la dirección 0x8000.

- Invocamos la función "gsinit", que es proporcionada por el compilador (no la tenemos que escribir nosotros) y es la encargada de inicializar las variables globales que tengamos..

- Invocamos la función "main".

El código anterior asume que se deben definir como mínimo 3 funciones:

void vblankISR(void) __critical __interrupt(0): rutina de servicio de interrupción sólo interrumpible por una interrupción no enmascarable (NMI). En la práctica es una rutina "normal" de interrupción que lo primero que hacer nada más entrar es deshabilitar las interrupciones y volver a habilitarlas antes de salir.

void nmISR(void) __critical __interrupt: rutina de servicio de interrupción no interrumpible y no enmascarable (NMI). Se ejecuta cuando se pulsa en botón "pause" de la consola.

void main(void): el punto de entrada de nuestro código.

Chip gráfico (VDP)

El chip gráfico de la Sega Master System es una versión simplificada del chip gráfico del MSX y sólo soporta un único modo de vídeo. El chip tiene una VRAM (RAM de vídeo) dedicada y separada de la RAM de la CPU. A dicha VRAM se accede mediante operaciones de puerto (instrucciones "in", "out" del Z80). La VRAM está organizada como sigue:

$4000 ---------------------------------------------------------------
      Sprite info table: contains x,y and tile number for each sprite
$3F00 ---------------------------------------------------------------
      Screen display: 32x28 table of tile numbers/attributes
$3800 ---------------------------------------------------------------
      Sprite/tile patterns, 256..447
$2000 ---------------------------------------------------------------
      Sprite/tile patterns, 0..255
$0000 ---------------------------------------------------------------

Esta forma de organizar la VRAM no es fija y puede cambiarse escribiendo en registros especiales del VDP aunque es la más habitual en todos los juegos de SMS y la que usaremos para la prueba de concepto.

Lo habitual para asegurarnos de que el VDP está correctamente inicializado es:

- Inicializar sus registros a unos valores por defecto.

- Inicializar toda la VRAM a 0.

A continuación el código utilizado para inicializar el VDP y que se coloca justo antes de llamar "gsinit" (que inicializa las variables globales):
init_vdp_data:
    .db #0x04, #0x80, #0x00, #0x81, #0xff, #0x82, #0xff, #0x85, #0xff, #0x86, #0xff, #0x87, #0x00, #0x88, #0x00, #0x89, #0xff, #0x8a
init_vdp_data_end:

...

    ; init vdp
    ld hl, #init_vdp_data
    ld b, #init_vdp_data_end - #init_vdp_data
    ld c, #0xbf
    otir
    ; clear vram
    ld a, #0x00
    out (#0xbf), a
    ld a, #0x40
    out (#0xbf), a
    ld bc, #0x4000    ; 16384 bytes of vram
clear_vram_loop:
    ld a, #0x00
    out (#0xbe), a
    dec bc
    ld a, b
    or c
    jp nz, clear_vram_loop

...

Para comprender el significado detallado de los datos "init_vdp_data" es mejor recurrir a la documentación del VDP, por ahora nos basta saber que con esos comandos, el VDP queda correctamente inicializado.

Fondo de la pantalla

La imagen en la pantalla de la Sega Master System está compuesta por 32 x 24 baldosas de 8 x 8 pixels cada bandolsa. Cada pixel está definido por 4 bits (máximo 15 colores simultáneos más el color 0 que siempre es transparente o negro). Cada color está definido por una paleta con dos bits por cada componente ("rrggbb").

La forma de definir los colores en las baldosas es algo críptica por la forma en que se definen los bits así que lo mejor es poner un ejemplo. Imaginemos que queremos que una baldosa sea un corazón rojo de bordes blancos y fondo transparente:
 color | símbolo | índice en la paleta
-------+---------+----------------------
transp |    -    | 0b0000 (0)
rojo   |    *    | 0b0001 (1)
blanco |    +    | 0b0010 (2)

--------
-++-++--
+**+**+-
+*****+-
-+***+--
--+*+---
---+----
--------

Para este caso, la baldosa quedaría definida de la siguiente manera:

const uint8_t HEART_PATTERN[32] = {
//  bit
//  0     1     2     3     cada byte: msb=pixel más izq, lsb=pixel más derecha
    0x00, 0x00, 0x00, 0x00,
    0x00, 0x6C, 0x00, 0x00,
    0x6C, 0x92, 0x00, 0x00,
    0x7C, 0x82, 0x00, 0x00,
    0x38, 0x44, 0x00, 0x00,
    0x10, 0x28, 0x00, 0x00,
    0x00, 0x10, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00
};

Cada fila de la baldosa está definida por 4 bytes (total 4 x 8 = 32 bytes por baldosa). Y en cada fila el byte 0 tiene los bits 0 de cada pixel, el byte 1 tiene los bits 1 de cada pixel, el tercer byte 2 los bits 2 de cada pixel y el byte 3 los bits 3 de cada pixel. En el ejemplo anterior, el dibujo sólo utiliza 3 colores (0, 1 y 2), por eso los bytes 2 y 3 están a 0 siempre.

En nuestro caso, a partir de una foto y utilizando el Gimp para aplicar el algoritmo de reducción de paleta Floyd-Steinberg, generamos una imagen de 128 x 120 pixels (16 x 15 baldosas) junto con una paleta reducida de 16 colores.

#include <stdint.h>
#include "ana.h"


__sfr __at (0xBE) VDP_DATA;
__sfr __at (0xBF) VDP_ADDRESS;


#define  PALETTE_OFFSET_TILES    0
#define  PALETTE_OFFSET_SPRITES  16


void write_palette(uint8_t offset, const uint8_t *palette) {
    VDP_ADDRESS = offset;    // start at color 0
    VDP_ADDRESS = 0b11000000;
    uint8_t n = 16;
    while (n > 0) {
        VDP_DATA = *palette;
        palette++;
        n--;
    }
}


void write_vram(const uint8_t *src, uint16_t size, uint16_t vram_addr) {
    VDP_ADDRESS = (uint8_t) (vram_addr & 0x00FF);
    VDP_ADDRESS = 0b01000000 | ((uint8_t) ((vram_addr >> 8) & 0x3F));
    while (size > 0) {
        VDP_DATA = *src;
        src++;
        size--;
    }
}


void write_vram_2(const uint8_t *src, uint16_t size, uint16_t vram_addr) {
    VDP_ADDRESS = (uint8_t) (vram_addr & 0x00FF);
    VDP_ADDRESS = 0b01000000 | ((uint8_t) ((vram_addr >> 8) & 0x3F));
    while (size > 0) {
        VDP_DATA = *src;
        VDP_DATA = 0;
        src++;
        size--;
    }
}


void vblankISR(void) __critical __interrupt(0) {
    uint8_t vdp_status = VDP_ADDRESS;
}


void nmISR(void) __critical __interrupt {
}


void main(void) {
    __asm__ ("di");
    // draw background image
    write_palette(PALETTE_OFFSET_TILES, ANA_PALETTE);
    write_vram(ANA_TILE_PATTERNS, 241 * 4 * 8, 0);    // 240 + 1 tile patterns --> vram pattern address (0x0000)
    write_vram_2(ANA_TILE_MAP, 32 * 24, 0x3800);        // 32 * 24 tiles --> vram tile map address (0x3800)
    // enable display
    VDP_ADDRESS = 0b01100000;
    VDP_ADDRESS = 0x81;
    __asm__ ("ei");
    while (1)
        ;
}

Compilamos:
$ /opt/sdcc/bin/sdasz80 -o crt0sms.rel crt0sms.s
$ /opt/sdcc/bin/sdcc -mz80 -c -o main.rel main.c
$ /opt/sdcc/bin/sdcc -mz80 --data-loc 0xC000 --no-std-crt0 -o main.ihx crt0sms.rel main.rel
$ objcopy -I ihex -O binary main.ihx main.sms

Y el fichero "main.sms" podemos ahora ejecutarlo con un simulador o incluso pasarlo a un cartucho flash para ejecutarlo en una Sega Master System real:



Sprites

El formato de baldosas descritp es el que se utiliza tanto para el fondo como para los sprites, con el añadido de que los sprites tienen su propia paleta, que puede ser diferente de la paleta de las baldosas del fondo.

La Sega Master System puede manejar hasta 64 sprites por hardware. La tabla de información de sprites ("sprite info table"), alojada a partir de la dirección 0x3F00 de la VRAM es una tabla de 256 bytes que almacena las coordenadas (x, y) de cada sprite y el número de baldosa que se usará para el sprite. Si asumimos que n = 0 ... 63 (0x00 ... 0x3F) tenemos que, dentro de la VRAM:

- 0x3F00 + n: es la coordenada "y" del sprite (un valor de 208 aquí significa que este es el final de la tabla de sprites y ya no hay más sprites que pintar).

- 0x3F80 + (n x 2): es la coordenada "x" del sprite.

- 0x3F81 + (n x 2): índice de la baldosa (0 a 255).

La VRAM permite definir hasta 512 baldosas distintas y mediante el registro 6 del VDP se configura si el índice del sprite hace referencia a las primeras 256 baldosas o a las últimas 256 baldosas de las 512. En nuestro caso (a través de "init_vdp_data") configuramos este registro para que las baldosas de los sprites sean las últimas 256 de todas las 512.

Interrupción vblank

La interrupción vblank es la parte del código donde habitualmente se realiza la escritura o modificación de los sprites, las baldosas, etc. ya que es en ese momento cuando se está produciendo una pausa entre un cuadra y el siguiente en la pantalla .

En nuestro caso colocaremos ahí un sencillo código que hace moverse al sprite a través de la pantalla rebotando en los bordes.

Código de la parte gráfica

A continuación se ve cómo quedaría todo el código de la parte gráfica (acceso al VDP, a la VRAM y la interrupción vblank):
#include <stdint.h>
#include "ana.h"


__sfr __at (0xBE) VDP_DATA;
__sfr __at (0xBF) VDP_ADDRESS;


const uint8_t SPRITE_PALETTE[16] = {
//        red   white
    0x00, 0x03, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};


const uint8_t SPRITE_PATTERN[32] = {
//  bit
//  0     1     2     3       each byte: msb=leftmost pixel, lsb=rightmost pixel
    0x00, 0x00, 0x00, 0x00,
    0x00, 0x6C, 0x00, 0x00,
    0x6C, 0x92, 0x00, 0x00,
    0x7C, 0x82, 0x00, 0x00,
    0x38, 0x44, 0x00, 0x00,
    0x10, 0x28, 0x00, 0x00,
    0x00, 0x10, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00
};


#define  PALETTE_OFFSET_TILES    0
#define  PALETTE_OFFSET_SPRITES  16


void write_palette(uint8_t offset, const uint8_t *palette) {
    VDP_ADDRESS = offset;    // start at color 0
    VDP_ADDRESS = 0b11000000;
    uint8_t n = 16;
    while (n > 0) {
        VDP_DATA = *palette;
        palette++;
        n--;
    }
}


void write_vram(const uint8_t *src, uint16_t size, uint16_t vram_addr) {
    VDP_ADDRESS = (uint8_t) (vram_addr & 0x00FF);
    VDP_ADDRESS = 0b01000000 | ((uint8_t) ((vram_addr >> 8) & 0x3F));
    while (size > 0) {
        VDP_DATA = *src;
        src++;
        size--;
    }
}


void write_vram_2(const uint8_t *src, uint16_t size, uint16_t vram_addr) {
    VDP_ADDRESS = (uint8_t) (vram_addr & 0x00FF);
    VDP_ADDRESS = 0b01000000 | ((uint8_t) ((vram_addr >> 8) & 0x3F));
    while (size > 0) {
        VDP_DATA = *src;
        VDP_DATA = 0;
        src++;
        size--;
    }
}


uint8_t sprite_x;
uint8_t sprite_y;
int8_t sprite_x_inc;
int8_t sprite_y_inc;


void vblankISR(void) __critical __interrupt(0) {
    uint8_t vdp_status = VDP_ADDRESS;
    // apply (sprite_x, sprite_y) coordinates to sprite vram
    VDP_ADDRESS = 0;
    VDP_ADDRESS = 0b01000000 | 0x3F;
    VDP_DATA = sprite_y;
    VDP_ADDRESS = 0x80;
    VDP_ADDRESS = 0b01000000 | 0x3F;
    VDP_DATA = sprite_x;
    // update (sprite_x, sprite_y)
    sprite_x += sprite_x_inc;
    if (sprite_x == (256 - 8))
        sprite_x_inc = -1;
    else if (sprite_x == 0)
        sprite_x_inc = 1;
    sprite_y += sprite_y_inc;
    if (sprite_y == (192 - 8))
        sprite_y_inc = -1;
    else if (sprite_y == 0)
        sprite_y_inc = 1;
}


void nmISR(void) __critical __interrupt {
}


void main(void) {
    __asm__ ("di");
    // draw background image
    write_palette(PALETTE_OFFSET_TILES, ANA_PALETTE);
    write_vram(ANA_TILE_PATTERNS, 241 * 4 * 8, 0);    // 240 + 1 tile patterns --> vram pattern address (0x0000)
    write_vram_2(ANA_TILE_MAP, 32 * 24, 0x3800);        // 32 * 24 tiles --> vram tile map address (0x3800)
    // draw sprite
    write_palette(PALETTE_OFFSET_SPRITES, SPRITE_PALETTE);
    VDP_ADDRESS = 0;      // border color = color 0 of sprite palette
    VDP_ADDRESS = 0x87;
    write_vram(SPRITE_PATTERN, 4 * 8, 0x2000);
    // locate sprite
    sprite_x = 80;
    sprite_y = 80;
    sprite_x_inc = 1;
    sprite_y_inc = 1;
    VDP_ADDRESS = (uint8_t) (0x3F00 & 0x00FF);
    VDP_ADDRESS = 0b01000000 | ((uint8_t) ((0x3F00 >> 8) & 0x3F));
    VDP_DATA = sprite_y;
    VDP_DATA = 208;         // end of sprite list (sprite_y = 208)
    VDP_ADDRESS = (uint8_t) (0x3F80 & 0x00FF);
    VDP_ADDRESS = 0b01000000 | ((uint8_t) ((0x3F80 >> 8) & 0x3F));
    VDP_DATA = sprite_x;
    VDP_DATA = 0;           // tile 0 of sprite tiles
    // enable display
    VDP_ADDRESS = 0b01100000;
    VDP_ADDRESS = 0x81;
    __asm__ ("ei");
    while (1)
        ;
}


Chip de sonido (PSG)

El chip de sonido SN76489 es un chip muy sencillo al que se accede mediante escrituras sucesivas en el registro 0x7F (es de sólo escritura) y en la documentación se explicmuy bien cómo se programa. En nuestro caso, para evitar estar "componiendo" o haciendo música desde cero para la prueba de concepto, se ha optado por recurrir al formato de música VGM ("Video Game Music"), documentado aquí y que es muy fácil de leer y de procesar pues es simplemente un volcado de los valores que deben escribirse en el registro del chip junto con comandos de espera.

Se definen dos funciones:

- void vgm_init(vgm_info *vgm, const uint8_t *file_data): que inicializa una estructura "vgm_info" a partir de los datos de una canción en formato VGM.

- void vgm_tick(vgm_info *vgm): que se encarga de escribir los datos en el chip de sonido con la cadencia indicada por la canción VGM. Esta función debe ser invocada en cada vblank del VDP.

typedef struct {
    const uint8_t *first_byte;
    const uint8_t *next_byte;
    uint16_t wait_counter;
} vgm_info;


void vgm_init(vgm_info *vgm, const uint8_t *file_data) {
    uint32_t version = *((uint32_t *) (file_data + 0x08));
    if (version < 0x00000150)
        vgm->next_byte = file_data + 0x40;
    else {
        uint32_t data_offset = *((uint32_t *) (file_data + 0x34));
        if (data_offset == 0x0000000C)
            vgm->next_byte = file_data + 0x40;
        else
            vgm->next_byte = file_data + data_offset + 0x34;
    }
    vgm->first_byte = vgm->next_byte;
    vgm->wait_counter = 0;
}


void vgm_tick(vgm_info *vgm) {
    if (vgm->wait_counter > 0) {
        vgm->wait_counter--;
        return;
    }
    const uint8_t *p = vgm->next_byte;
    if (*p == 0x50) {
        vgm->wait_counter = 0;
        while (*p == 0x50) {
            p++;
            PSG = *p;
            p++;
        }
    }
    while ((*p == 0x61) || (*p == 0x62) || (*p == 0x63)) {
        if ((*p == 0x62) || (*p == 0x63)) {
            vgm->wait_counter++;
            p++;
        }
        else {
            p++;
            uint16_t num_samples = *((uint16_t *) p);
            p += 2;
            //vgm->wait_counter += num_samples / 882;    // convert samples to ticks (requires stdlib because of integer division)
            //
            // aproximate num_samples / 882 with num_samples / 768 = num_samples / (256 * 3)
            // (1 / 3) * 65536 = 21845, so:
            // num_samples / 768 = ((num_samples / 256) * 21845) / 65536
            // num_samples / 768 = ((num_samples >> 8) * 21845) >> 16
            // num_samples / 768 = (ns * 21845) >> 24
            // num_samples / 768 = (ns * (16384 + 4096 + 512 + 256 + 32 + 4)) >> 24
            // num_samples / 768 = (ns * (16384 + 4096 + 512 + 256 + 32 + 4)) >> 24
            // num_samples / 768 = ((ns << 14) + (ns << 12) + (ns << 8) + (ns << 5) + (ns << 2) + ns) >> 24
            uint32_t aux = num_samples;
            aux = ((aux << 14) + (aux << 12) + (aux << 8) + (aux << 5) + (aux << 2)) >> 24;
            vgm->wait_counter = aux;
        }
    }
    while ((*p & 0x70) == 0x70)    // wait n + 1 samples, 1 tick = 882 samples, so ignore 0x7X commands
        p++;
    if (*p == 0x66) {
        vgm->wait_counter = 0;
        vgm->next_byte = vgm->first_byte;
    }
    else
        vgm->next_byte = p;
}

Como puede verse, desde la función main se invoca "vgm_init" indicando un puntero a los datos VGM a procesar y se invoca "vgm_tick" en cada interrupción vblank. La única parte algo críptica del código de "vgm_tick" es cuando hay que calcular la cantidad de ticks de espera a partir de los samples, pues la fórmula oficial es $\frac{samples}{882}$ para el caso PAL (50 Hz) y $\frac{samples}{735}$ para el caso NTSC (60 Hz).

En nuestro caso se ha optado por buscar un término medio y calcular los ticks de espera mediante la fórmula $\frac{samples}{768}$ (independientemente de que la consola se sea PAL o NTSC). Esta división entera se ha realizado mediante desplazamientos y sumas para evitar tener que usar la división proporcionada por la librería de SDCC (el Z80 no tiene instrucción de división).

Desde vgmrips.net pueden descargarse cientos de canciones en formato VGM para el chip SN76489.

Conclusiones

La Sega Master System es un sistema potente para su época y muy sencillo de programar comparado con la NES y otras consolas de su generación. El formato de baldosas es quizá lo más críptico que tiene pero en cuanto se le coge el truco, no es tan complicado aunque es mejor recurrir a herramientas externas para ayudarnos en las conversiones de datos. Todo el código puede descargarse de la sección soft.

[ añadir comentario ] ( 837 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 2.9 / 1413 )
Desarrollo en C++ de un pequeño juego para Game Boy Advance 
A raiz del anterior post en el que se introdujeron los conceptos y el código básico para el desarrollo de aplicaciones y juegos para la Game Boy Advance, he desarrollado un pequeño juego de tablero basado en bloques deslizantes e inspirado en los puzzles intermedios que aparecen en el juego "Mario + Rabbids Kingdom Battle".

Mecánica de los bloques deslizantes

Disponemos de un tablero de juego dividido en cuadrícula (en nuestro caso de 9x9 huecos), la mayoría de ellas transitables y otras no transitables. En el tablero de juego se disponen entre 1 y 4 bloques (siempre en huecos transitables), cada uno de ellos de un color. Por cada bloque habrá un hueco en el tablero de juego que será el sitio destino donde debe llevarse a dicho bloque, es decir, si en nuestro tablero tenemos 3 bloques, uno rojo, otro verde y otro azul, en el tablero existirán también 3 huecos marcados como destino del bloque rojo, destino del bloque verde y destino del bloque azul, respectivamente.



El jugador mueve los bloques por turnos con la restricciones siguientes:

- Un bloque sólo puede moverse en una dirección en la que el tablero sea transitable y no haya otro bloque impidiendo su movimiento.

- La longitud del recorrido de un bloque en una dirección será siempre la máxima posible hasta que el bloque se encuentre con un obstáculo (hueco no transitable u otro bloque). dicho de otra forma, los bloques se mueven hasta que "chocan" con el borde del tablero (hueco no transitable) o con otro bloque, no pueden frenarse a mitad de recorrido.





El objetivo del juego es llevar cada bloque hasta su respectivo hueco (el bloque rojo hacia el hueco rojo, el bloque verde hacia el hueco verde y así con todos) antes de que se cumpla el tiempo de juego.

Esta mecánica es la que se puede encontrar en algunos mini juegos de puzzle de "Mario + Rabbids Kingdom Battle", para Nintendo Switch.


(Imagen extraida de Neoseeker)

Experiencia previa

Tengo experiencia cero en desarrollar juegos y esta es, por tanto, la primera vez que lo hago. Me lo he planteado como un reto de "puedo hacerlo" y, para el lector experimentado en el desarrollo de juegos, es muy probable que haya hecho algunas (o muchas) cosas bastante mal, usando de forma incorrecta algún patrón de diseño o incluso haber implementado pésimamente algunos aspectos. Vayan por delante mis disculpas a esos desarrolladores.

Pantallas y diseño

Las pantallas del juego las he representado como un grafo en el que los nodos son las pantallas en sí y los arcos son las transiciones entre pantallas. El grafo del juego sería el siguiente:



Modelo de máquina de estados

A partir de este grafo se desarrolló un modelo sencillo en el que cada nodo es un "objeto pantalla". Se definió la clase "Screen" y la clase "ScreenManager". Cada pantalla debe heredar de la clase "Screen" y será un objeto, mientras que la clase "ScreenManager" se instancia una sola vez y se encarga de gestionar la navegación entre las pantallas del juego.

Como vimos en el anterior post relativo al desarrollo para Game Boy Advance, la forma ideal de acceder a la memoria de vídeo (VRAM, paleta, etc.) para que no se produzca ruido o errores de pintado es durante el tiempo de retrazo vertical así que lo que haremos será orientar toda la arquitectura en ese sentido. Al principio se inicializan todas las pantallas (objetos de algún tipo que herede de "Screen") que va a tener el juego (ya sea de forma global o de forma local a la función main) y se inicializa el objeto de tipo "ScreenManager" pasándole la pantalla inicial.

#include "Screen.H"
#include "ScreenTitle.H"
#include "ScreenMenu.H"
#include "ScreenFileSelectionMenu.H"
#include "ScreenBoardSelectionMenu.H"
#include "GameStatus.H"
#include "ScreenDeleteFileMenu.H"
#include "ScreenConfirmStartBoardMenu.H"
#include "ScreenBoard.H"
#include "ScreenPauseMenu.H"

using namespace std;
using namespace avelino;

typedef void (*fptr)(void);

#define  ISR_PTR   *((fptr *) 0x03007FFC)
#define  IME       *((volatile uint32_t *) 0x04000208)
#define  IE        *((volatile uint16_t *) 0x04000200)
#define  IF        *((volatile uint16_t *) 0x04000202)
#define  IFBIOS    *((volatile uint16_t *) 0x03007FF8)
#define  DISPSTAT  *((volatile uint16_t *) 0x04000004)

void isr()  __attribute__((target("arm")));

void isr() {
    // mark interrupt as served
    IF = 1;
    IFBIOS |= 1;
}

void enableInterrupts() {
    IME = 0;
    ISR_PTR = isr;
    DISPSTAT = ((uint16_t) 1) << 3;
    IE = 1;   // v-blank
    IME = 1;
}

int main() {
    enableInterrupts();
    GameStatus status;
    // define screens
    ScreenTitle st;
    ScreenFileSelectionMenu sfsm;
    ScreenBoardSelectionMenu sbsm;
    ScreenDeleteFileMenu sdfm;
    ScreenConfirmStartBoardMenu scsbm;
    ScreenBoard sb;
    ScreenPauseMenu spm;
    // link screens
    st.nextScreen = &sfsm;
    sfsm.backScreen = &st;
    sfsm.nextScreen = &sbsm;
    sbsm.backScreen = &sfsm;
    sbsm.deleteFileScreen = &sdfm;
    sbsm.boardSelectedScreen = &scsbm;
    sdfm.backScreen = &sbsm;
    sdfm.justDeletedScreen = &sfsm;
    scsbm.backScreen = &sbsm;
    scsbm.yesScreen = &sb;
    sb.pauseScreen = &spm;
    sb.afterFinishScreen = &sbsm;
    spm.continueScreen = &sb;
    spm.finishScreen = &sbsm;
    // start screen manager
    ScreenManager m;
    m.init(st, status);
    while (true) {
        asm volatile ("swi 0x05");    // wait for v-blank
        m.onVBlank();
    }
}

En el bucle principal del juego lo único que se hace es esperar a que se produzca la interrupción de retrazo vertical y, en cuanto se produce, se invoca la función miembro "ScreenManager::onVBlank()".

// Screen.H

        class InterScreenData {   // any data
        };

        class Screen {
            public:
                virtual void onLoad(InterScreenData *dataFromPreviousScreen) = 0;
                virtual Screen *onVBlank(Keypad &keypad) = 0;                              // return "this" to stay on same screen or other screen to switch to
                virtual InterScreenData *onUnload() = 0;
        };

        class ScreenManager {
            public:
                Screen *currentScreen;
                Keypad keypad;
                ScreenManager() : currentScreen(NULL) { };
                void init(Screen &initialScreen, InterScreenData &initialInterScreenData);
                void onVBlank();
        };

// Screen.cc

void ScreenManager::init(Screen &initialScreen, InterScreenData &initialInterScreenData) {
    this->keypad.init();
    this->currentScreen = &initialScreen;   // start with first screen
    this->currentScreen->onLoad(&initialInterScreenData);
}


void ScreenManager::onVBlank() {
    this->keypad.onVBlank();
    Screen *next = this->currentScreen->onVBlank(this->keypad);
    if (next != this->currentScreen) {    // switch to another screen
        InterScreenData *isd = this->currentScreen->onUnload();   // get data from outgoing screen
        next->onLoad(isd);                                        // and passing it to incomming screen
        this->currentScreen = next;
    }
}

La función miembro "init" del objeto de clase "ScreenManager" inicializa el teclado, asigna la pantalla actual a la pantalla que le llega por parámetros, invoca la función miembro "onLoad" de dicha pantalla inicial y regresa. A partir de entonces, cada vez que haya un retrazo vertical, se invocará la función miembro "onVBlank()" del objeto de tipo "ScreenManager" que, a su vez, invocará la función miembro "onVBlank()" del objeto de tipo "Screen" que tiene como pantalla actual. Esta función miembro "Screen::onVBlank()" devuelve "this" en caso de que queramos continuar en esta pantalla para el siguiente retrazo o un puntero a otro objeto "Screen" en caso de que queramos cambiar de pantalla para el siguiente retrazo. En caso de que devuelva diferente de la pantalla actual ("this"), se invoca a la función miembro "onUnload" de la pantalla "saliente" y a continuación a la función miembro "onLoad" de la pantalla "entrante". Con esta mecánica podemos implementar la lógica de navegación entre pantallas.

Funciones miembro de la clase Screen

class InterScreenData {   // any data
};

class Screen {
    public:
        virtual void onLoad(InterScreenData *dataFromPreviousScreen) = 0;
        virtual Screen *onVBlank(Keypad &keypad) = 0;                              // return "this" to stay on same screen or other screen to switch to
        virtual InterScreenData *onUnload() = 0;
};

La clase Screen define 3 funciones miembro virtuales puras que deberán ser implementadas por las subclases que hereden de ella:

void Screen::onLoad(InterScreenData *dataFromPreviousScreen)

Esta función miembro se invoca en el momento de cargar una pantalla y antes de invocar por primera vez a su función miembro "onVBlank". En esta función miembro se inicializarán las variables y los datos de la pantalla así como la configuración del controlador LCD, los backgrounds, los sprites y demás, de tal manera que todo quede "listo para pintar" esta pantalla. Lo recomentable es, al principio de esta función miembro, poner a 1 el bit 7 del registro DISPCNT para deshabilitar el acceso a la VRAM desde el controlador LCD y permitir que la CPU acceda a la VRAM de forma rápida y, justo antes de salir de esta función miembro, configurar el mismo registro DISPCNT con el modo gráfico, los backgrounds, sprites y demás opciones que se necesiten para esta pantalla.

Screen *Screen:onVBlank(Keypad &keypad)

Esta función miembro se llamará en cada retrazo vertical mientras ésta pantalla sea la pantalla activa. Recibe por parámetro una referencia a un objeto de clase Keypad (donde puede leerse el estado de las teclas) y devuelve un puntero a una pantalla válida: si devuelve "this" se permanece en la pantalla (objeto Screen) actual y en el siguiente retrazo vertical se volverá a invocar de nuevo a la misma función miembro "onVBlank" del mismo objeto Screen. En caso de que se devuelva un objeto (una pantalla, de tipo Screen) diferente a "this", el ScreenManager interpretará que se desea cambiar de pantalla por lo que invocará la función miembro "onUnload" de la pantalla "saliente" y a continuación a la función miembro "onLoad" de la pantalla "entrante". Para el siguiente retrazo vertical se invocará la función miembro "onVBlank" de la pantalla entrante y así sucesivamente.

InterScreenData *Screen::onUnload()

Esta función miembro es la que invoca el objecto de tipo ScreenManager cuando la función miembro "onVBlank" de pantalla actual devuelve una pantalla distinta a ella misma, porque entiende que se quiere pasar a otra pantalla. Como se puede ver, "onUnload" devuelve un puntero a un objeto de tipo "InterScreenData" que es pasado en el "onLoad" de la pantalla entrante. La intención de este objeto de tipo "InterScreenData" es de servir como estado del juego, de tal manera que todas las pantallas (objetos de tipo base Screen) vean los mismos datos del juego (en otras palabras, una forma elegante de tener "variables globales"). Nótese que la clase InterScreenData es una clase vacía, cuando hacemos el juego decidimos qué queremos que sea InterScreenData, ya que puede ser cualquier cosa.

Toda la mecánica del juego se define mediante estas tres funciones miembro en cada pantalla.

Ejemplo de implementación de un pantalla: ScreenTitle



Ésta es la pantalla de arranque. Muestra un fondo prediseñado en modo 3 (bitmap de 15 bits de color por pixel) y muestra el texto "- Press START-" haciendo que parpadee de forma suave hasta que el usuario pulsa "START" para continuar a la siguiente pantalla.

- En la función miembro "onLoad" se pone a 1 el bit 7 de DISPCNT para deshabilitar el LCD y acelerar el acceso a VRAM desde la CPU, luego se copia la imagen de fondo de la ROM a una parte de la VRAM, se inicializa la paleta para los sprites, se pintan las letras como sprites, se configura el alpha blending (parpadeo suave) para los sprites y, antes de salir, se configura el controlador LCD: modo 3, background layer 2 y objetos (sprites) habilitados.

void ScreenTitle::print(const char *text) {
    char *p = (char *) text;
    // calculate string length
    uint16_t n = 0;
    while (*p != 0) {
        p++;
        n++;
    }
    this->objUsed = n;
    // draw sprites as text
    const int16_t Y = 100;
    int16_t x = 120 - ((n << 3) >> 1);
    uint16_t i = 0;
    p = (char *) text;
    while (*p != 0) {
        OAM[i++] = Y | (((uint16_t) 1) << 13);
        OAM[i++] = x & 0x01FF;
        OAM[i++] = 512 + ((*p - ' ') << 1);
        i++;
        x += 8;
        p++;
    }
}

void ScreenTitle::onLoad(InterScreenData *dataFromPreviousScreen) {
    this->interScreenData = dataFromPreviousScreen;
    this->alpha = 0;
    this->objUsed = 0;
    this->timer = 5 * 60;   // 5 seconds
    // force blank to access VRAM
    DISPCNT = ((uint16_t) 1) << 7;
    // draw on framebuffer
    //memcpy16(MODE3_VRAM_FB, ANA, 240 * 160);
    memcpy16(MODE3_VRAM_FB, TITLE_RGB_IMAGE, 240 * 160);
    // configure OBJ 256 color palette (all colors white)
    memcpy16(OBJ_PALETTE, PALETTE, 256);
    // load OBJ tiles from FONT_1
    memcpy16((uint16_t *) OBJ_TILES_VRAM, (uint16_t *) FONT_1, 3072);
    // configure OBJs to print a text (this call updates this->objUsed to free the sprites on unload
    this->print("- Press START -");
    // 1st target is OBJ, 2nd target is BG2, fx = alpha blending
    BLDCNT = (((uint16_t) 1) << 4) | (((uint16_t) 1) << 6) | (((uint16_t) 1) << 10);
    // transparency at 50% (8/16 for both OBJ and BG2 intensities)
    BLDALPHA = (((uint16_t) this->alpha) << 8) | ((uint16_t) (16 - this->alpha));
    // enable mode 3 with background layer 2, enable obj, enable obj 1-d mapping
    DISPCNT = ((uint16_t) 3) | (((uint16_t) 1) << 10) | (((uint16_t) 1) << 12) | (((uint16_t) 1) << 6);
}


- En la función miembro "onVBlank()" se actualiza el registro de alpha blending para producir el efecto de parpadeo suave. Si se detecta que se pulsa el botón START se devuelve "nextScreen" (que será la pantalla de selección de fichero), en caso contrario se devuelve siempre "this" para indicar que seguimos en esta pantalla.

Screen *ScreenTitle::onVBlank(Keypad &keypad) {
    // recalculate sprite alpha blending
    this->alpha += ALPHA_INC;
    uint16_t alphaIntegerPart = this->alpha >> 8;
    if (alphaIntegerPart > 16) {
        this->alpha = 0;
        alphaIntegerPart = 0;
    }
    BLDALPHA = (((uint16_t) alphaIntegerPart) << 8) | ((uint16_t) (16 - alphaIntegerPart));
    // next screen when pressing START, else keep screen
    if (keypad.mask & KEY_START)
        return this->nextScreen;
    else
        return this;
}


- En la función miembro "onUnload", se reinicia el registro de alpha blending y los sprites usados.

InterScreenData *ScreenTitle::onUnload() {
    // free the sprites (OBJs) used
    uint16_t n = this->objUsed;
    uint16_t j = 0;
    while (n > 0) {
        OAM[j] = SCREEN_HEIGHT | (((uint16_t) 1) << 13);
        OAM[j + 1] = SCREEN_WIDTH & 0x01FF;
        j += 4;
        n--;
    }
    // disable alpha blending
    BLDCNT = 0;
    return this->interScreenData;
}


Esta forma de escribir el código facilita mucho poder concentrarse en cada pantalla del juego por separado, pues cada pantalla tiene su terna onLoad/onVBlank/onUnload. Hay que tener en cuenta algunas cosas:

- Hay que garantizar que las cosas que inicialicemos y configuremos en onLoad luego las dejemos con su valor por defecto o desactivadas en el onUnload, para que, si la siguiente pantalla no las vaya a usar, no le afecten.

- En la función miembro "onVBlank" hay que tratar de hacer la menor cantidad de cosas posible ya que se trata de una función miembro que se invoca en el retrazo vertical, y, por tanto, se dispone de una pequeña ventana de ejecución hasta el siguiente retrazo. De la misma manera, dentro de la propia función lo ideal es hacer los cambios en la VRAM o en los registro del LCD lo antes posible, por si es inevitable que su ejecución se prolongue más ciclos de los que dura el retrazo. Cuantas más cosas dejemos preparadas y precalculadas en la función miembro "onLoad", mejor.

Teniendo esta filosofía en mente ya podemos ir diseñando las diferentes pantallas del juego.

Diagrama de clases



La herencia y el polimorfismo nos permiten factorizar muy fácilmente algunas pantallas que son muy similares unas a otras. Por ejemplo ScreenFileSelectionMenu, ScreenPauseMenu, ScreenYesNoMenu, ScreenConfirmStartBoardMenu y ScreenDeleteFileMenu, se aprovechan de esta característica de C++ y la única clase que debe lidiar con el hardware (LCD, teclas, etc.) es ScreenMenu, ya que el resto heredan de ella y permiten cambiar los mensajes, las opciones y las acciones del menú de forma muy elegante, tan sólo redefiniendo las funciones miembro virtuales puras con otras adecuadas.

La pantalla de juego principal: ScreenBoard

Vamos a centrarnos en la clase ScreenBoard, que es la que implementa la pantalla de juego principal, que gestiona el tablero de juego y la mecánica del mismo. A continuación podemos ver un grafo con la máquina de estados de esta pantalla:



Durante el estado "MARK" se muestra un marcador que parpadea sobre un bloque y dándole a las flechas cambiamos de bloque.



Si, estando en el estado "MARK" pulsamos A, nos ponemos en modo "STEER" ("girar las ruedas") que lo que hace es calcular los posibles movimientos del bloque actual y marcarlos con flechas. Una vez en el estado "STEER" las flechas sirven para empujar el bloque seleccionado en la dirección deseada pasando al modo "MOVE" o podemos pasarnos al modo "MARK" si no queremos mover el bloque, pulsando B de nuevo.



El estado "MOVE" es el estado en el que la pantalla realiza la animación de movimiento de un bloque hasta su nueva posición. Cuando el bloque que se está moviendo llega a su posición final se "consolida" en el tablero y se evalúa el tablero para saber si el usuario ha ganado. Si el usuario ha ganado se va al estado "END_WIN", si no ha ganado aún, se pasa al estado "MARK".



En caso de que el cronómetro de la fase llegue a cero se pierde (estado "END_LOSE") mientras que si logramos ubicar a todos los bloques en sus correspondientes rectángulos antes de que el cronómetro de la fase llegue a cero, ganamos (estado "END_WIN").



"ScreenBoard::onVBlank" puede devolver tres valores:

- this: Mientras el tablero esté en juego de forma normal.

- this->pauseScreen: Puntero a pantalla de pausa, en caso de pulsar Start estando en el estado "MARK".

- this->afterFinishScreen: Puntero a pantalla después de finalización (tanto si gana como si pierde, desde el estado "FINISHED").

Mecanismo de guardado de partidas

En la Game Boy Advance es posible utilizar diferentes mecanismos de guardado de datos de forma no volátil (que no se pierdan al apagar la consola) tanto basados en SRAM no volátil como basados en Flash o en EEPROM. En nuestro caso hemos optado por usar el mecanismo de SRAM no volátil, que es el más sencillo. Sólo hay que tener en cuenta las siguientes restricciones:

- Tenemos 64 Kbytes de memoria SRAM no volátil a partir de 0x0E000000 (hasta 0x0E00FFFF).

- Es una zona de memoria direccionable a nivel de byte (debe escribirse y leerse en esta zona de memoria mediante escrituras y lecturas de un byte de anchura, no de 16 ni de 32 bits).

- Para acceder a dicha zona de memoria es necesario escribir previamente un 3 en el registro WAITCNT para indicar al controlador del bus que dicha memoria es lenta y requiere de 8 ciclos de espera. Lo ideal es guardar el valor actual de WAITCNT antes de acceder, hacer WAITCNT = 3, acceder al espacio de memoria y al final restaurar el valor de WAITCNT anterior.

Para que los cartuchos flash y los emuladores detecten correctamente el tipo de backup que vamos a utilizar en nuestro juego es necesario situar una cadena de caracteres en algún sitio de la rom del juego para que sea reconocible el tipo de backup que se usará. En nuestro caso, al usar SRAM colocaremos la cadena "SRAM_V113" en una posición de memoria cualquiera de la ROM que sea múltiplo de 2 bytes. La cadena debe ocupar una cantidad múltiplo de 4 bytes de longitud y los bytes restantes deberán contener ceros. En nuestro caso lo más sencillo es incluir dicha cadena en el array "CARTRIDGE_HEADER", justo después de la cabecera de Nintendo:

__attribute__((section(".cartridge_header")))
const uint8_t CARTRIDGE_HEADER[256] = {
    0x3E, 0x00, 0x00, 0xEA,     // branch to +0x100   0b 11101010 00000000 00000000 00111110 --> 0xEA 0x00 0x00 0x3E

                            0x24, 0xff, 0xae, 0x51, 0x69, 0x9a, 0xa2, 0x21, 0x3d, 0x84, 0x82, 0x0a,   // nintendo logo
    0x84, 0xe4, 0x09, 0xad, 0x11, 0x24, 0x8b, 0x98, 0xc0, 0x81, 0x7f, 0x21, 0xa3, 0x52, 0xbe, 0x19,
    0x93, 0x09, 0xce, 0x20, 0x10, 0x46, 0x4a, 0x4a, 0xf8, 0x27, 0x31, 0xec, 0x58, 0xc7, 0xe8, 0x33,
    0x82, 0xe3, 0xce, 0xbf, 0x85, 0xf4, 0xdf, 0x94, 0xce, 0x4b, 0x09, 0xc1, 0x94, 0x56, 0x8a, 0xc0,
    0x13, 0x72, 0xa7, 0xfc, 0x9f, 0x84, 0x4d, 0x73, 0xa3, 0xca, 0x9a, 0x61, 0x58, 0x97, 0xa3, 0x27,
    0xfc, 0x03, 0x98, 0x76, 0x23, 0x1d, 0xc7, 0x61, 0x03, 0x04, 0xae, 0x56, 0xbf, 0x38, 0x84, 0x00,
    0x40, 0xa7, 0x0e, 0xfd, 0xff, 0x52, 0xfe, 0x03, 0x6f, 0x95, 0x30, 0xf1, 0x97, 0xfb, 0xc0, 0x85,
    0x60, 0xd6, 0x80, 0x25, 0xa9, 0x63, 0xbe, 0x03, 0x01, 0x4e, 0x38, 0xe2, 0xf9, 0xa2, 0x34, 0xff,
    0xbb, 0x3e, 0x03, 0x44, 0x78, 0x00, 0x90, 0xcb, 0x88, 0x11, 0x3a, 0x94, 0x65, 0xc0, 0x7c, 0x63,
    0x87, 0xf0, 0x3c, 0xaf, 0xd6, 0x25, 0xe4, 0x8b, 0x38, 0x0a, 0xac, 0x72, 0x21, 0xd4, 0xf8, 0x07,

    'E', 'J', 'E', 'M', 'P', 'L', 'O', 0x00, 0x00, 0x00, 0x00, 0x00,      // game title

    'A', 'E', 'J', 'S',        // game code 'A' + two characters + 'S' (for spanish)

    '0', '1',            // maker code

    0x96,                // fixed value

    0x00,                // GBA

    0x00,                // device type

    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,          // reserved

    0x00,                // software version

    0xC1,                // complement check

    0x00, 0x00,          // reserved

    'S', 'R', 'A', 'M', '_', 'V', '1', '1', '3', 0x00, 0x00, 0x00   // backup id string after official header to indicate the backup method (SRAM at 0x0E000000)
};

De esta forma, usando las clases SaveFile y SaveFileManager, podemos cargar y guardar partidas ya que internamente utilizan las funciones "memset8" y "memcpy8" que aseguran un acceso a nivel de byte a la zona de memoria entre 0x0E000000 y 0x0E00FFFF y, además, controlan que el registro WAITCNT=3 cuando se accede dicha zona.

Diseño de niveles

Los niveles se definen en Level.H y Level.cc como un array de constantes que se aloja en el mismo espacio de memoria que el resto de la ROM del cartucho. Cada nivel incluye:

- Nombre corto (2 letras).

- Nombre largo (9 letras).

- Configuración de tablero en sí (un valor de 16 bits por cada baldosa, con 9x9 baldosas).

- Tiempo en minutos y segundos para resolverlo.

- Vector con niveles que se desbloquean cuando este nivel se termina.

El diseño de niveles para este juego creo que excede mi capacidad como desarrollador por varias razones:

- Los niveles deberían ser de complejidad creciente, eligiendo una cantidad de bloques y un tablero acordes al progreso actual.

- También hay que elegir bien el tiempo máximo de resolución y buscar un equilibrio entre que el juego no sea muy fácil (tableros sencillos y/o tiempo largo) y que no sea muy difícil (tableros complejos y/o tiempos de resolución cortos).

Para este caso, y por pura "curiosidad teórico-computacional", desarrollé un pequeño programa (también en C++ pero no para compilar de forma cruzada, sino para compilar y ejecutar en un ordenador) que permite generar niveles jugables aleatorios a partir de ciertos parámetros iniciales. El programa parte de una situación final de ganar (todos los bloques en sus correspondientes baldosas destino) y va generando movimientos hacia atrás hasta obtener un tablero en el que lo bloques estén dispersos y se pueda usar como tablero de juego inicial para un nivel.

# primero generamos un fichero binario con la forma del nivel,
# indicando las celdas transitables con 0x0000 y las celdas no transitables con 0x0080 (nótese que los valores son little-endian)
# 9x9 valores de 16 bits = 162 bytes
echo "0000 0000 8000 8000 0000 0000 0000 0000 0000" | xxd -r -ps > board1.bin
echo "0000 0000 8000 8000 0000 0000 0000 0000 0000" | xxd -r -ps >> board1.bin
echo "0000 0000 0000 0000 0000 0000 0000 0000 0000" | xxd -r -ps >> board1.bin
echo "0000 0000 0000 0000 0000 0000 0000 0000 0000" | xxd -r -ps >> board1.bin
echo "0000 0000 0000 0000 0000 0000 0000 0000 8000" | xxd -r -ps >> board1.bin
echo "0000 0000 8000 0000 0000 0000 0000 0000 8000" | xxd -r -ps >> board1.bin
echo "0000 0000 8000 0000 0000 0000 0000 0000 8000" | xxd -r -ps >> board1.bin
echo "0000 0000 8000 8000 8000 0000 0000 8000 8000" | xxd -r -ps >> board1.bin
echo "0000 0000 8000 8000 8000 0000 0000 8000 8000" | xxd -r -ps >> board1.bin
# luego decidimos los bloques que queremos generar con una máscara de bits: el bloque rojo vale 1, el verde 2, el azul 4 y el amarillo 8
# pasando un 7 significa que queremos generar un nivel que use los bloques rojo, verde y azul
# el tercer parámetro es el número de iteraciones máximas
./level_generator board1.bin 7 2000

Mediante este algoritmo se han generado los niveles A4 al C1. Los niveles A1 al A3 se han generado a mano y del C2 en adelante los niveles están vacíos, no son "ganables" y por ahora no se pueden desbloquear. El código fuente está disponible para quien quiera completar niveles o rediseñarlos.



Todo, el código fuente junto con una ROM precompilada, puede descargarse de la sección soft.


[ 2 comentarios ] ( 42272 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 3081 )
Programación de la Game Boy Advance 
Durante los años 2004 a 2006 hice varios desarrollos para la Game Boy Advance (GBA). En aquella época utilicé varios recursos muy útiles y que aún hay diponibles, como el proyecto DevkitPro, que permite instalar una toolchain completa y librerías para programar diferentes consolas (NintendoDS, GBA, Gamecube, Wii, Switch y otras). Sin embargo tras estos años he querido abordar de nuevo el desarrollo para Game Boy Advance pero desde cero, partiendo de un compilador y una toolchain bare-metal estándar (arm-none-eabi).

De cara a realizar un proyecto de desarrollo para Game Boy Advance u otras consolas de forma más "profesional" recomiendo siempre acudir a DevkitPro. Este artículo debe leerse como una prueba de concepto y una forma de entender mejor cómo funcionan las interioridades de la Game Boy Advance ya que definiremos en detalle y desde cero la cabecera de la ROM, el linker script, el código de arranque, etc. El código que generemos será igual de válido que el generado por DevkitPro con la diferencia de que DevkitPro dispone de multitud de librerías y herramientas que facilitan el desarrollo y lo de nosotros es "masoquismo".

Prerrequisitos

El único prerrequisito será tener instalada la toolchain bare-metal para ARM (arm-none-eabi). La construcción de dicha toolchain a partir de las fuentes está descrita en esta entrada de este mismo blog.

Características técnicas de la Game Boy Advance

La Game Boy Advance fue una consola portátil muy exitosa que sacó Nintendo en 2001, sucesora y compatible hacia atrás con las Game Boy y Game Boy Color. Es la primera de su clase provista de un procesador de 32 bits (un ARM7TDMI, con repertorio de instrucciones ARMv4T e instrucciones de multiplicación y división entera implementadas en hardware). No me detendré aquí a enumerar todas las características técnicas de la Game Boy Advance, para ello es mejor dirigirse a sitios como GBATEK o blogs como el de Jamie Stewart. Hay muchos recursos en internet disponibles. Me centraré en lo que necesitamos para construir un programa, una ROM mínima que se pueda ejecutar en una Game Boy Advance y utilizando sólo una toolchain bare-metal, por lo que nos veremos obligados a escribir el linker script y el código de startup desde cero.

Formato de una ROM de GBA

La Gameboy Advance utiliza un formato de ROM parecido al formato de ROM de sus precedesoras:
Offset (hex)  Longitud (dec)  Descripción
0000          4               Código ARM de 32 bits que se ejecuta al arrancar el cartucho.
0004          156             156 bytes fijos que contienen en logo de Nintendo comprimido. Siempre son los mismos bytes.
00A0          12              Nombre del juego (ASCII).
00AC          4               Código del juego (ASCII).
00B0          2               Código del fabricante (ASCII).
00B2          1               Valor fijo (96h).
00B3          1               Código del dispositivo (00h).
00B4          1               Tipo de dispositivo (00h).
00B5          7               Reservado (todo a 00h).
00BC          1               Versión del software (lo que queramos aquí).
00BD          1               Checksum calculado a partir de los bytes A0 a BC (ambos inclusive).
00BE          2               Reservado (todo a 00h)
00C0          n               Resto de la ROM (el código y los datos)

Para contextualizar este mapa (que es el mapa de memoria de un cartucho de Game Boy Advance), veamos el mapa de memoria de la consola en sí:
Inicio    Fin       Tamaño      Descipción
00000000  00003FFF  16 Kbytes   ROM BIOS
02000000  0203FFFF  256 Kbytes  WRAM (lenta)
03000000  03007FFF  32 Kbytes   WRAM (rápida)
04000000  040003FE              Registros E/S
05000000  050003FF  1 Kbyte     Paletas LCD
06000000  06017FFF  96 Kbytes   VRAM LCD
07000000  070003FF  1 Kbyte     Sprites LCD
08000000  09FFFFFF  32 Mbytes   ROM/Flash cartucho
0A000000  0BFFFFFF  32 Mbytes   ROM/Flash cartucho
0C000000  0DFFFFFF  32 Mbytes   ROM/Flash cartucho
0E000000  0E00FFFF  64 Kbytes   RAM cartucho

La RAM de cartucho es opcional (no todos los cartuchos la tienen), mientras que las tres zonas de ROM/Flash de cartucho se diferencian por la cantidad de estados de espera que necesitan para ser accedidas desde la CPU. La primera zona (0x08000000 a 0x09FFFFFF) es la más rápida y en la que se asume que se encuentra el cartucho en sí, las otras dos zonas suelen ser utilizadas como memorias flash para almacenar datos no volátiles (partidas guardadas, por ejemplo), aunque algunos fabricantes de cartuchos optan por utilizar los 64 Kbytes de RAM de cartucho y utilizar una NVRAM en su lugar (mantenida con una pila o cualquier otra tecnología no volátil).

Teniendo en cuenta este mapa de memoria de la consola, las ROM de Game Boy Advance no empiezan en 0, sino en 0x08000000 (hay que sumar 0x08000000 a los offsets de la primera tabla).


El proceso de arranque

En el proceso de arranque, la consola comprueba la cabecera del cartucho insertado (accediendo a 0x08000000) y, si la cabecera es válida (logo de Nintendo y checksum), la consola hace un salto incondicional a la dirección de memoria 0x08000000. Como en esa posición de memoria sólo disponemos de 4 bytes, ya que en el offset 0x08000004 del cartucho está el logo de Nintendo y el resto de la cabecera, lo que se suele colocar aquí es una instrucción de salto a una dirección de memoria que esté más allá de la cabecera. En nuestro caso, por comodidad, hacemos un salto siempre a 0x08000100:

instrucción: "b 0x08000100" $\rightarrow$ opcode: 0xEA00003E $\rightarrow$ bytes en little endian: 0x3E, 0x00, 0x00, 0xEA

El byte de checksum se calcula realizando el siguiente cálculo con los bytes situados entre los offsets 0x080000A0 y 0x080000BC (ambos inclusive):

    int32_t chk = 0;
    for (int n = 0xA0; n <= 0xBC; n++)
        chk = chk - headerData[n];
    chk = (chk - 0x19) & 0xFF;

Para el resto de detalles de la cabecera del cartucho es mejor consultar la sección correspondiente en GBATEK. En nuestro caso, que hemos titulado a nuestro juego "EJEMPLO" y le hemos puesto el código de juego "AEJS" y el código de desarrollador "01", nos salen los siguientes datos de cabecera.

const uint8_t CARTRIDGE_HEADER[256] = {
    0x3E, 0x00, 0x00, 0xEA,     // branch to +0x100   0b 11101010 00000000 00000000 00111110 --> 0xEA 0x00 0x00 0x3E

                            0x24, 0xff, 0xae, 0x51, 0x69, 0x9a, 0xa2, 0x21, 0x3d, 0x84, 0x82, 0x0a,   // nintendo logo
    0x84, 0xe4, 0x09, 0xad, 0x11, 0x24, 0x8b, 0x98, 0xc0, 0x81, 0x7f, 0x21, 0xa3, 0x52, 0xbe, 0x19,
    0x93, 0x09, 0xce, 0x20, 0x10, 0x46, 0x4a, 0x4a, 0xf8, 0x27, 0x31, 0xec, 0x58, 0xc7, 0xe8, 0x33,
    0x82, 0xe3, 0xce, 0xbf, 0x85, 0xf4, 0xdf, 0x94, 0xce, 0x4b, 0x09, 0xc1, 0x94, 0x56, 0x8a, 0xc0,
    0x13, 0x72, 0xa7, 0xfc, 0x9f, 0x84, 0x4d, 0x73, 0xa3, 0xca, 0x9a, 0x61, 0x58, 0x97, 0xa3, 0x27,
    0xfc, 0x03, 0x98, 0x76, 0x23, 0x1d, 0xc7, 0x61, 0x03, 0x04, 0xae, 0x56, 0xbf, 0x38, 0x84, 0x00,
    0x40, 0xa7, 0x0e, 0xfd, 0xff, 0x52, 0xfe, 0x03, 0x6f, 0x95, 0x30, 0xf1, 0x97, 0xfb, 0xc0, 0x85,
    0x60, 0xd6, 0x80, 0x25, 0xa9, 0x63, 0xbe, 0x03, 0x01, 0x4e, 0x38, 0xe2, 0xf9, 0xa2, 0x34, 0xff,
    0xbb, 0x3e, 0x03, 0x44, 0x78, 0x00, 0x90, 0xcb, 0x88, 0x11, 0x3a, 0x94, 0x65, 0xc0, 0x7c, 0x63,
    0x87, 0xf0, 0x3c, 0xaf, 0xd6, 0x25, 0xe4, 0x8b, 0x38, 0x0a, 0xac, 0x72, 0x21, 0xd4, 0xf8, 0x07,

    'E', 'J', 'E', 'M', 'P', 'L', 'O', 0x00, 0x00, 0x00, 0x00, 0x00,      // game title

    'A', 'E', 'J', 'S',        // game code 'A' + two characters + 'S' (for spanish)

    '0', '1',            // maker code

    0x96,                // fixed value

    0x00,                // GBA

    0x00,                // device type

    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,          // reserved

    0x00,                // software version

    0xC1,                // complement check

    0x00, 0x00           // reserved
};

Linker script y código de arranque

El linker script es el script de "ld" (la herramienta de enlazado de GCC) que utilizaremos para decidir cómo se distribuyen el código y los datos en el espacio de memoria del cartucho:

SECTIONS {
    . = 0x08000000 ;
    .cartridge_header : {
        startup.o (.cartridge_header)
    }
    . = 0x08000100 ;
    .text : {
        _linker_code = . ;
        startup.o (.startup)
        *(.text)
        *(.text.*)
        *(.rodata*)
        *(.gnu.linkonce.t*)
        *(.gnu.linkonce.r*)
    }
    .preinit_array : {
        __preinit_array_start = . ;
        *(.preinit_array)
        __preinit_array_end = . ;
    }
    .init_array : {
        __init_array_start = . ;
        *(.init_array)
        __init_array_end = . ;
    }
    .fini_array : {
        __fini_array_start = . ;
        *(.fini_array)
        __fini_array_end = . ;
    }
    .ctors : {
        __CTOR_LIST__ = . ;
        LONG((__CTOR_END__ - __CTOR_LIST__) / 4 - 2)
        *(.ctors)
        LONG(0)
        __CTOR_END__ = . ;
    }
    .dtors : {
        __DTOR_LIST__ = . ;
        LONG((__DTOR_END__ - __DTOR_LIST__) / 4 - 2)
        *(.dtors)
        LONG(0)
        __DTOR_END__ = . ;
    }
    flash_sdata = . ;
    . = 0x03000000 ;
    ram_sdata = . ;
    .data : AT (flash_sdata) {
        _linker_data = . ;
        *(.data)
        *(.data.*)
        *(.gnu.linkonce.d*)
    }
    ram_edata = . ;
    data_size = ram_edata - ram_sdata;
    ram_sbssdata = . ;
    .bss : AT (LOADADDR(.data) + SIZEOF(.data)) {
        _linker_bss = . ;
        *(.bss)
        *(.bss.*)
        *(.gnu.linkonce.b.*)
        *(.COMMON)
    }
    ram_ebssdata = . ;
    bssdata_size = ram_ebssdata - ram_sbssdata;
    _linker_end = . ;
    end = . ;
}

Como se puede apreciar, al principio (zona marcada de verde) se fija el offset en 0x08000000 y, a partir de ese punto, lo primero que se hace es insertar una sección que hemos llamado ".cartridge_header" (el nombre es arbitrario) y que el linker script buscará en "startup.o", a continuación se fija el offset en 0x08000100 y a partir de ahí se coloca todo el código compilado, teniendo cuidado de que lo primero que se coloca a partir de 0x08000100 es la sección que hemos llamado ".startup" (el nombre también es arbitrario) y a continuación el resto del código (secciones ".text", ".rodata", etc. Estos nombres no son arbitrarios, son estándar de GCC).

Nótese que después de colocar todo el código, el linker script fija el offset en 0x03000000 (como se vio antes, esta sería la zona WRAM rápida de 32 Kbytes, zona marcada de rojo) para definir ahí las variables globales, tanto las inicializadas (".bss") como las sin inicializar (".data"). Mediante la palabra reservada "AT" le decimos al linker script que los datos "estarán" (futuro) en la WRAM (0x03000000) pero físicamente se encuentran ("por ahora") en "flash_sdata" (después del código). Esta "rareza" se explica porque los valores con los que se inicializa la RAM están en la ROM del cartucho y, antes de invocar a la función "main" tenemos que copiar dichos datos de la ROM a la RAM.

El código de arranque lo tenemos definido en "startup.cc". En este fichero tenemos definido un array constante de 256 bytes al que le asociamos la sección ".cartridge_header" (para que el linker script meta esos 256 bytes al principio de la ROM del cartucho, dirección 0x08000000) y también varias funciones que son las que realizarán todo el proceso de arranque.

__attribute__((section(".cartridge_header")))
const uint8_t CARTRIDGE_HEADER[256] = {
    0x3E, 0x00, 0x00, 0xEA,     // branch to +0x100   0b 11101010 00000000 00000000 00111110 --> 0xEA 0x00 0x00 0x3E

                            0x24, 0xff, 0xae, 0x51, 0x69, 0x9a, 0xa2, 0x21, 0x3d, 0x84, 0x82, 0x0a,   // nintendo logo
    0x84, 0xe4, 0x09, 0xad, 0x11, 0x24, 0x8b, 0x98, 0xc0, 0x81, 0x7f, 0x21, 0xa3, 0x52, 0xbe, 0x19,
    0x93, 0x09, 0xce, 0x20, 0x10, 0x46, 0x4a, 0x4a, 0xf8, 0x27, 0x31, 0xec, 0x58, 0xc7, 0xe8, 0x33,
    0x82, 0xe3, 0xce, 0xbf, 0x85, 0xf4, 0xdf, 0x94, 0xce, 0x4b, 0x09, 0xc1, 0x94, 0x56, 0x8a, 0xc0,
    0x13, 0x72, 0xa7, 0xfc, 0x9f, 0x84, 0x4d, 0x73, 0xa3, 0xca, 0x9a, 0x61, 0x58, 0x97, 0xa3, 0x27,
    0xfc, 0x03, 0x98, 0x76, 0x23, 0x1d, 0xc7, 0x61, 0x03, 0x04, 0xae, 0x56, 0xbf, 0x38, 0x84, 0x00,

...

};

Código ARM y código THUMB

El procesador ARM7TDMI de la Game Boy Advance, al igual que otros procesadores ARMv4 y superiores, tiene dos repertorios de instrucciones:

1.- Un repertorio de instrucciones de 32 bits, con datos de 32 bits (modo ARM).

2.- Otro repertorio de instrucciones de 16 bits, con datos de 32 bits (modo THUMB).

El modo THUMB es igual de potente que el modo ARM, con el inconveniente de que las instrucciones no son igual de ortogonales y que para realizar la funcionalidad de algunas instrucciones ARM son necesarias varias instrucciones THUMB, pero con la ventaja de una mayor velocidad de ejecución y una mayor densidad de código. En el caso de la Game Boy Advance, el ARM7TDMI puede ejecutar código en ambos modos e incluso se puede pasar de modo ARM a modo THUMB y viceversa en cualquier momento de la ejecución de un programa.

A nivel hardware la Game Boy Advance posee un bus de 16 bits de anchura para la ROM del cartucho por lo que la opción recomendable para ejecutar el grueso del código es usar modo THUMB (el modo ARM funciona sin problemas sobre el espacio de direcciones de la ROM del cartucho, pero obliga al procesador a realizar dos lecturas de la ROM por cada instrucción y eso se nota en el rendimiento y el consumo). Por tanto el "coste" de tener un repertorio de instrucciones "recortado" (THUMB) se compensa por la velocidad de ejecución y por el menor consumo de energía.

El compilador GCC permite definir mediante opciones ("-marm" y "-mthumb") el tipo de código que generará por defecto y, de forma adicional, para introducir excepciones, es posible especificar que determinadas funciones sean compiladas en modo "arm" o en modo "thumb".

Teniendo en cuenta lo dicho y que los cartuchos ROM son arrancados siempre en modo ARM (la instrucción alojada al principio del cartucho, en 0x08000000, se lee en modo ARM, es una instrucción de 32 bits), lo que haremos será lo siguiente:

1.- Se compila por defecto todo el código del proyecto en modo THUMB (opción "-mthumb" del compilador).

2.- En el fichero "startup.cc", la función "_startup()" (que está en la sección ".startup" y, por tanto, su código se aloja justo a partir de 0x08000100, como se vio en el linker script), se marca como de tipo "arm", para que el compilador genere dicho código en modo "arm" (instrucciones de 32 bits) y, dentro de esta función, sólo hacemos un salto a la función "_startup_thumb()".

3.- En el mismo fichero "startup.cc" la función "_startup_thumb()", al no tener ningún atributo especial, se compila en modo THUMB (debido a la opción "-mthumb" que le pasamos al compilador) y el compilador genera, además, el código necesario para pasar de modo ARM a modo THUMB (por la llamada desde "_startup()" que está en modo ARM a "_startup_thumb()" que está en modo THUMB y gracias a la opción "-mthumb-interwork" que también se le pasa al compilador).

4.- Es la función "_startup_thumb()" (que ya se ejecuta en modo THUMB) la que realiza la inicialización de todo y la encargada de invocar la función "main()".

void _startup() __attribute__((section(".startup"), naked, target("arm")));
void _thumb_startup();

void _startup() {
    _thumb_startup();
}

void _thumb_startup() {
    _initDataRAM();
    _initBssRAM();
    _callConstructors();
    _callInitArray();
    main();
    _callFiniArray();
    _callDestructors();
    while (true)
        ;
}

De esta forma ya todo el código que generemos en nuestro proyecto estará en modo THUMB (aunque nada nos impide escribir funciones que se compilen en código ARM añadiendo el atributo "target("arm")" a las mismas).

Compilar y generar la ROM

Ahora que tenemos nuestro código de arranque y el linker script, podemos compilar y enlazar todo nuestro código con la toolchain bare-metal de GCC:

# con esta línea compilamos "startup.cc", el código de arranque
RUTA_BARE_METAL_ARM/bin/arm-none-eabi-g++ -std=c++11 -march=armv4t -mcpu=arm7tdmi -mtune=arm7tdmi -fno-exceptions -fno-rtti -nostartfiles -mthumb -mthumb-interwork -o startup.o startup.cc

# con esta línea compilamos el resto de ficheros fuente que necesitemos
RUTA_BARE_METAL_ARM/bin/arm-none-eabi-g++ -std=c++11 -march=armv4t -mcpu=arm7tdmi -mtune=arm7tdmi -fno-exceptions -fno-rtti -nostartfiles -mthumb -mthumb-interwork -o main.o main.cc

# enlazamos (generamos un fichero .elf) indicando al enlazador
# que use nuestro script de enlazado personalizado "gba.ld"
RUTA_BARE_METAL_ARM/bin/arm-none-eabi-g++ -std=c++11 -march=armv4t -mcpu=arm7tdmi -mtune=arm7tdmi -fno-exceptions -fno-rtti -nostartfiles -mthumb -mthumb-interwork -Wl,-Tgba.ld -o main.elf startup.o main.o

# generamos el binario (.gba) a partir del .elf
RUTA_BARE_METAL_ARM/bin/arm-none-eabi-objcopy -O binary main.elf main.gba

El binario generado ("main.gba") es una imagen válida de un cartucho de Game Boy Advance, por lo que puede ser ejecutada tanto en un simulador de Game Boy Advance como en una Game Boy Advance real (mediante un cartucho flash o similar).

Primera prueba de concepto

Con el linker script anterior más el código de arranque que hemos definido ya podemos hacer una primera prueba de concepto.

#include <stdint.h>
#include "Ana.H"   // .H con datos generados a partir de una imagen de 240x160 (RGB555)

using namespace std;
using namespace avelino;

#define  DISPCNT         *((volatile uint16_t *) 0x04000000)
#define  MODE3_VRAM_FB    ((uint16_t *) 0x06000000)

void memcpy16(uint16_t *dest, const uint16_t *source, uint32_t size) {
    uint16_t *s = (uint16_t *) source;
    while (size > 0) {
        *dest = *s;
        s++;
        dest++;
        size--;
    }
}

int main() {
    // force blank to access VRAM
    DISPCNT = ((uint16_t) 1) << 7;
    // draw on framebuffer
    memcpy16(MODE3_VRAM_FB, ANA, 240 * 160);
    // enable mode 3 with background layer 2
    DISPCNT = ((uint16_t) 3) | (((uint16_t) 1) << 10);
    while (true)
        ;
}

En este código lo único que hacemos es deshabilitar el LCD (para facilitar el acceso a la VRAM), copiar una imagen predefinida al framebuffer del LCD, habilitar el LCD en el modo gráfico 3 (que permite poner imágenes de color RGB555 (15 bits) en un frame buffer) y quedarnos en un blucle infinito a modo de "parada". En GBATEK puede verse cómo funcionan los diferentes modo gráficos de la Game Boy Advance.



Gestión interrupciones

El manejo de interrupciones es clave para sacar el máximo partido a cualquier plataforma embebida y en el caso de la Game Boy Advance, es importante tenerlas controladas tanto de cara a la gestión adecuada del controlador de LCD como de cara a minimizar el consumo de energía (recordemos que se trata de una consola portátil alimentada a baterías).

Para la gestión de interrupciones hay que tener en cuenta varias cosas:

- El registro IME (Interrupt Master Enable) permite habilitar y deshabilitar las interrupciones de forma global.

- El registro IE (Interrupt Enable) permite habilitar determinadas interrupciones de forma específica.

- Cuando de produce una interrupción se ejecuta un código específico de la BIOS de la Game Boy Advance que, a su vez, hace una llamada en modo ARM (no THUMB) a la dirección almacenada en 0x03007FFC (parte alta de la WRAM). Por tanto en esta posición de memoria escribiremos el puntero a nuestra rutina de interrupción, que deberá estar compilada en modo ARM (no THUMB).

- Una vez dentro de la interrupción hay que leer el registro IF (Interrupt Request Flags) para saber el origen de cada interrupción y escribir en él al final para marcar la interrupción como "servida".

Consideremos la interrupción que probablemente más vamos a necesitar: la interrupción V-Blank. Mediante interrupción sabremos cuando la CPU puede acceder de forma rápida a los registros de control de la pantalla y a la VRAM, de tal manera que no se produzcan "glitches" en la visualización de la imagen y podremos "prepararlo" todo para el siguiente frame (la Game Boy Advance pinta 60 imágenes por segundo, se producen 60 interrupciones de V-Blank por segundo).

Para configurar y usar la interrupción V-Blank podremos escribir el siguiente código:

typedef void (*fptr)(void);

#define  ISR_PTR  *((fptr *) 0x03007FFC)
#define  IME      *((volatile uint32_t *) 0x04000208)
#define  IE       *((volatile uint16_t *) 0x04000200)
#define  IF       *((volatile uint16_t *) 0x04000202)
#define  IFBIOS   *((volatile uint16_t *) 0x03007FF8)

void isr()  __attribute__((target("arm")));

void isr() {
    //
    // ...
    //
    // mark v-blank interrupt as served
    IF = 1;
    IFBIOS |= 1;
}

void enableInterrupts() {
    IME = 0;
    ISR_PTR = isr;
    DISPSTAT = ((uint16_t) 1) << 3;
    IE = 1;   // enable v-blank
    IME = 1;
}

int main() {
    enableInterrupts();
    //
    // ...
    //
    while (true) {
        asm volatile ("swi 0x05");    // wait for v-blank
    }
}

La función "isr()" la tendríamos definida como de tipo ARM (no THUMB) y, nada más empezar la función "main()", invocaríamos a la función "enableInterrupts()" que se encargaría de configurar las interrupciones y de escribir la dirección de memoria de la función "isr()" en 0x03007FFC. Una vez tenemos habilitadas las interrupciones, en el bucle infinito de espera incovamos una rutina de la BIOS de la Game Boy Advance ("swi 0x05") que pone a la CPU en modo de bajo consumo hasta que se produce una interrupción, con lo que conseguimos una ejecución más eficiente (gastamos menos batería). Para más detalles sobre el funcionamiento de las interrupciones u otras fuentes de interrupción, ver el apartado correspondiente en GBATEK.

Nótese que se produce una especie de contradicción en el manejo de interrupciones ya que, si bien dijimos antes que la forma más rápida y eficiente de ejecutar código en la Game Boy Advance, es en modo THUMB, ahora resulta que el código de interrupción se invoca en modo ARM. En una interrupción es cuando mas "prisa" tengo, ya que necesito despacharla rápido antes de que llegue la siguiente. Esta contradicción se suele resolver de varias formas:

A.- Dejando que el código de interrupción sea ARM, asegurarnos de que hacemos pocas cosas en la rutina de interrupción "isr()" y dejar el grueso de operaciones que debemos hacer entre cada V-Blank para justo después de la instrucción asm volatile ("swi 0x05"). Esta es la forma más sencilla de ejecutar código THUMB entre retrazos verticales, ya que el código que se ejecuta es THUMB (estaría en la función "main()").

B.- No podemos hacer que la función "isr()" sea THUMB pero, como compilamos con el flag "-mthumb-interwork" sí que podemos llamar desde "isr()" a otra función que sí sea THUMB (el compilador genera un pequeño overhead de 2 o 3 instrucciones adicionales) y de esta forma manejamos la interrupción mediante código THUMB.

C.- La forma más avanzada: ejecutar la función "isr()" en WRAM. En "startup.cc", antes de invocar "main()", se copia el código de la función "isr()" (que habremos compilado en modo ARM) al principio de la WRAM y las variables globales se ponen después. El puntero a "isr()" que se usa no es el del código de "isr()" que está en la ROM del cartucho, sino el puntero al código copiado a WRAM que, aunque se ejecuta en modo ARM, va mucho más rápido, puesto que la WRAM está dentro del procesador. El mayor inconveniente de esta técnica es que, si ya de por sí tenemos sólo 32 Kbytes de WRAM, podemos mermar aún mas dicha memoria (necesaria para variables globales y para la pila) si no hacemos una función "isr()" pequeña. Por otro lado tenemos la ventaja de que es la forma más rápida de ejecutar código ARM.

Para nuestra siguiente prueba de concepto optamos por el mecanismo más sencillo: dejar que la función "isr()" sea de tipo ARM y "dejarla" en la ROM del cartucho (no copiarla a WRAM), ya que no vamos a ejecutar muchas instrucciones en ella y así se mantiene el código más fácil de entender.

Segunda prueba de concepto

Partiendo de la primera prueba de concepto, vamos a mover un sprite por la pantalla, actualizando su posición sólo durante el retrazo vertical (V-Blank). Para utilizar el motor de sprites por hardware de la Game Boy Advance debemos acceder a las zona de memoria OAM (Object Attribute Memory), la paleta y una parte de la VRAM, según indica el apartado correspondiente en la documentación GBATEK.

int main() {
    enableInterrupts();
    // force blank to access VRAM
    DISPCNT = ((uint16_t) 1) << 7;
    // draw on framebuffer
    memcpy16(MODE3_VRAM_FB, ANA, 240 * 160);
    // configure OBJ 256 color palette (all colors white)
    OBJ_PALETTE[0] = 0;       // transparent (color don't care)
    OBJ_PALETTE[1] = 0;       // black
    OBJ_PALETTE[2] = 0x7FFF;  // white
    // OBJ tile 0
    memcpy16((uint16_t *) OBJ_TILES_VRAM, BALL_TILE, 32);  // MUST copy 16 bit words
    // configure OBJ 0
    ballX = 120 - 4;
    ballY = 80 - 4;
    //     (y, no rotation/scaling, enabled, no mosaic, 256 colors with 1 palette, shape = square)
    OAM[0] = (ballY & 0x00FF) | ((uint16_t) 1) << 13;
    //     (x, no horizontal flip, no vertical flip, size = 8x8)
    OAM[1] = (ballX & 0x01FF);
    //     (tile number = 512 (first obj tile with mode 3), priority = 0)
    OAM[2] = 512;
    // enable mode 3 with background layer 2, enable obj, enable obj 1-d mapping
    DISPCNT = ((uint16_t) 3) | (((uint16_t) 1) << 10) | (((uint16_t) 1) << 12) | (((uint16_t) 1) << 6);
    ballDX = 1;
    ballDY = 1;
    while (true) {
        asm volatile ("swi 0x05");    // wait for v-blank
    }
}

Asignamos una paleta muy sencilla en la posición de memoria OBJ_PALETTE (color 0 transparente, color 1 negro y color 2 blanco), inicializamos una baldosa de 8x8 con el dibujo de una pequeña pelota en la posición de memoria OBJ_TILES_VRAM (baldosa 0) e inicializamos los atributos del sprite 0 (posición de memoria OAM) para que el sprite empiece en el centro de la pantalla.

Definimos 4 variables globales para la posición y la velocidad del sprite en X y en Y e implementamos una sencilla mecánica de movimiento y detección de colisiones en la rutina de interrupción ("isr()").

int32_t ballX, ballY, ballDX, ballDY;

void isr()  __attribute__((target("arm")));

void isr() {
    // calculate next location
    ballX += ballDX;
    ballY += ballDY;
    // check collisions
    if (ballX < 0) {
        ballX = 0;
        ballDX = 1;
    }
    else if (ballX > (240 - 8)) {
        ballX = 240 - 8;
        ballDX = -1;
    }
    if (ballY < 0) {
        ballY = 0;
        ballDY = 1;
    }
    else if (ballY > (160 - 8)) {
        ballY = 160 - 8;
        ballDY = -1;
    }
    // locate sprite
    OAM[0] = (ballY & 0x00FF) | ((uint16_t) 1) << 13;
    OAM[1] = (ballX & 0x01FF);
    // mark interrupt as served
    IF = 1;
    IFBIOS |= 1;
}




Ahora tenemos a nuestro sprite "pelotita" danzando por la pantalla. Todo el código está disponible en la sección soft.

[ añadir comentario ] ( 2312 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 3028 )

| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Siguiente> >>