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 ] ( 2 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 2.9 / 8 )

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