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: casada 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 ] ( 137 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 2.3 / 312 )
Programación de la GameBoy 
A lo largo de este post desgranaremos, paso a paso y desde cero, cómo programar en C para esta mítica consola de mano. Usaremos como base el compilador cruzado SDCC y la documentación que hay disponible.

Instalación del SDCC

Lo primero que hay que hacer es instalar o compilar el compilador SDCC. En caso de que vayamos a compilarlo para Linux o para algún Unix, nos descargaremos el código fuente de http://sdcc.sourceforge.net y compilaremos con:

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

De esta forma tendremos instalado el compilador cruzado SDCC (se deshabilitan los targets pic14 y pic16 para evitar la dependencia con "gputils"). En nuestro caso sólo necesitamos los target "z80" y "sm83" (el chip SM83 es la CPU de 8 bits que trae la GameBoy, una variante del Z80 en la que cambian sólo algunas instrucciones).

Documentación sobre el estándar

No es mi intención detallar en este post todos los entresijos de la arquitectura de la GameBoy ni de su modelo de programación. Toda la información al respecto la he extraido básicamente de aquí y de aquí, así como de otras fuentes de fácil acceso en internet. Me centraré en lo principal y necesario para obtener una ROM funcional para GameBoy.

Estructura de la ROM de un cartucho y del resto de memoria

Para empezar nos centraremos en el uso de una ROM básica de 32 Kbytes sin conmutación de bancos de memoria (hay otros tipos de ROM, pero este es el más básico):

Después de arrancar, la ROM de arranque comprueba el logo y el checksum del cartucho insertado y, si es correcto, salta a la dirección de memoria 0x0100, que es donde debe residir la primera instrucción del cartucho. Como se puede apreciar en la estructura del cartucho, en esta zona apenas caben 4 bytes (a partir de 0x0104 vienen los datos del logo de Nintendo, el nombre del juego, la licencia, el checksum, etc.), por lo que lo habitual es colocar ahí una instrucción de salto hacia una dirección donde esté el resto del código: por ejemplo 0x0150 u otra. Aquí puede verse con detalle todo el mapa de memoria de la GameBoy. La zona de los vectores de interrupción, que está antes de 0x0100, la veremos más adelante, cuando usemos las interrupciones.

Código de arranque

Lo que haremos será definir nuestro propio código de arranque "crt0gb.s" que genere los bytes específicos de la cabecera de un cartucho GameBoy (de 0x0100 a 0x014F) y que, a continuación, defina las áreas donde deben estar alojado tanto el código y los datos de sólo lectura (de 0x0150 a 0x7FFF, 32 KBytes de ROM) como los datos de lectura y escritura (0xC000 a 0xDFFF, 8 KBytes de RAM).

    .module crt0gb
    .globl _main
    .area _HEADER (ABS)

    .org 0x0100

    nop
    jp init

    ; nintendo logo
    .db 0xCE, 0xED, 0x66, 0x66, 0xCC, 0x0D, 0x00, 0x0B, 0x03, 0x73, 0x00, 0x83, 0x00, 0x0C, 0x00, 0x0D, 0x00, 0x08, 0x11, 0x1F, 0x88, 0x89, 0x00, 0x0E, 0xDC, 0xCC, 0x6E, 0xE6, 0xDD, 0xDD, 0xD9, 0x99, 0xBB, 0xBB, 0x67, 0x63, 0x6E, 0x0E, 0xEC, 0xCC, 0xDD, 0xDC, 0x99, 0x9F, 0xBB, 0xB9, 0x33, 0x3E
    ; game title (16 bytes)
    .ascii "PRUEBA"
    .db 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
    ; licensee code (0 for non super gameboy games)
    .db 0x00, 0x00, 0x00
    ; cartridge type (0 for ROM only)
    .db 0x00
    ; ROM size (0 for 32 KBytes)
    .db 0x00
    ; RAM size (0 for none)
    .db 0x00
    ; country code (1 for non japanese)
    .db 0x01
    ; licensee code (0xA4 for Konami)
    .db 0xA4
    ; version number
    .db 0x00
    ; header checksum (run "header_checksum_calc < main.gb" to get this value)
    .db 0x83   ; PUT HERE THE VALUE CALCULATED BY header_checksum_calc AND RECOMPILE
    ; checksum of all cartridge (GameBoy ignores this)
    .db 0x00, 0x00

    .org 0x0150

init:
    call gsinit
    call _main
exit_loop::
    jp exit_loop

    .area _HOME
    .area _CODE
    .area _GSINIT

gsinit::
    .area _GSFINAL
    ret

    .area _DATA
    .area _BSS
    .area _HEAP

Los diferentes campos de la cabecera de un cartucho de GameBoy (entre las direcciones 0x0100 y 0x014F) se detallan aquí. Como se puede ver en la posición 0x0100 se ponen las instrucciones "nop" y "jp init", a continuación se definen todos los campos de la cabecera del cartucho y es a partir de la posición de memoria 0x0150 donde se pone el resto del código y se aloja la etiqueta "init". "call gsinit" es la parte encargada de inicializar variables globales y a continuación se hace un "call _main" para que se invoque la función "main" que se haya definido.

El trabajo con el SDCC es más artesanal que con el GCC ya que en SDCC no existe el concepto de "linker script" como en GCC y los processos de compilación y enlazado están algo más entremezclados. Ahora podemos definir un "main.c" que simplemente pinte la valdosa (tile) 0 en el extremo superior izquierdo de la pantalla:

#include <stdint.h>

#define  IE        *((uint8_t *) 0xFFFF)
#define  LCDC      *((uint8_t *) 0xFF40)
#define  STAT      *((uint8_t *) 0xFF41)
#define  BGP       *((uint8_t *) 0xFF47)
#define  BG_TILES  ((uint8_t *) 0x8800)
#define  BG_DATA   ((uint8_t *) 0x9800)

const uint8_t TILE0[16] = {
    0b01010101, 0b00110011,
    0b01010101, 0b00110011,
    0b01010101, 0b00110011,
    0b01010101, 0b00110011,
    0b01010101, 0b00110011,
    0b01010101, 0b00110011,
    0b01010101, 0b00110011,
    0b01010101, 0b00110011
};

void memcpy(void *dest, void *src, uint16_t n) {
    uint8_t *d = (uint8_t *) dest;
    uint8_t *s = (uint8_t *) src;
    while (n > 0) {
        *d = *s;
        d++;
        s++;
        n--;
    }
}

void main() {
    // disable LCD before accessing VRAM
    while ((STAT & 0x03) != 0x01)
        ;
    LCDC = 0x00;
    // copy tile data to tile data VRAM
    memcpy(BG_TILES, TILE0, 16);
    // disable all LCD interrupts
    STAT = 0x00;
    // disable interrupts
    IE = 0x00;
    // configure palette
    BGP = 0b11100100;     // 0b11 for full black, 0b10 for dark gray, 0b01 for light gray and 0b00 for white
    // enable LCD, disable window, background tile data at 0x8800, background tile indices at 0x9800, obj not displayed, background display on
    LCDC = 0x81;
    // with LCD enabled, wait vblank before accessing VRAM
    while ((STAT & 0x03) != 0x01)
        ;
    BG_DATA[0] = 0x80;
    // halt
    while (1)
        ;
}

Para hacer este código sí hay que echar más mano de la documentación sobre el controlador gráfico de la GameBoy. En este caso se ha optado por un ejemplo muy simple:

1. Se deshabilita el controlador gráfico en el registro LCDC. Para hacerlo es necesario esperar a un retrazo vertical (vblank). Nintendo recomienda encarecidamente hacer esta espera previa antes de apagar el controlador LCD ya que, de lo contrario, puede dañarse la pantalla LCD (!).

2. Copiamos los 16 bytes de la baldosa (tile) 0 desde el array TILE0 a la VRAM que alberga los datos de las baldosas. La baldosa son simplemente 8 rayas verticales con las diferentes tonalidades de gris.

3. Se deshabilitan todas las interrupciones (registros STAT e IE a 0x00). Por ahora no estamos usando el mecanismo de interrupciones.

4. Configuramos la paleta (registro BGP).

5. Habilitamos de nuevo el controlador gráfico LCD.

6. Esperamos al siguiente retrazo vertical (vblank).

7. Escribimos en la VRAM encargada de indexar las valdosas que queremos mostrar, la baldosa 0 en la esquina superior izquierda.

8. Terminamos: bucle infinito.

Como se puede ver, se trata de un ejemplo muy simple y minimalista, pero que funciona. Ya tenemos preparado nuestro entorno de desarrollo para GameBoy.

Como emulador recomiendo el VisuaBoyAdvance-M, que es open source y muy estable. Aunque también nos podemos llevar la ROM a cualquier otro emulador o a un cartucho Flash que tengamos conectado a nuestra GameBoy física.


Interrupciones

De cara a hacer desarrollos más serios se hace necesario el uso y el aprovechamiento de las interrupciones en el Z80. El sistema de interrupciones en el Z80 depende de la implementación y, en el caso concreto del procesador SM83 y de la GameBoy, disponemos de las siguientes fuentes de interrupción (detalladas aquí):

- Int 0x40: VBlank.
- Int 0x48: Interrupción asociada al registro STAT del LCD.
- Int 0x50: Interrupción de timer.
- Int 0x58: Interrupción de puerto serie.
- Int 0x60: Interrupción de botones.

El valor "int 0xNN" hace referencia a la dirección de memoria del vector. En este caso, la dirección a la que salta el procesador cuando se produce una interrupción de retrazo vertical (VBlank) es la dirección 0x0040. Recordemos en la imagen del mapa de memoria, que las posiciones de memoria que están entre 0x0000 y 0x00FF se corresponden con los vectores de interrupción.

Modificaremos el ejemplo anterior para que el pintado de las baldosas se realice en la interrupción de retrazo vertical (VBlank). Para habilitar y trabajar con la interrupción VBlank hacemos lo siguiente:

1. Definimos en "crt0gb.s" el vector de interrupciones en la ubicación correcta (0x0040).

2. Habilitamos las interrupciones a nivel de sistema mediante la escritura en el registro IE (Interrupt Enable, dirección 0xFFFF).

3. Habilitamos las interrupciones a nivel de CPU mediante la instrucción ensamblador "ei".

En nuestro caso, para que la generación de código sea más cómoda, se define el nombre "vblankISR" como nombre que debe tener la función encargada de ejecutarse cada vez que haya un retrazo vertical. Dicho nombre se declara en "crt0gb.s" y se pone una instrucción "call _vblankISR" seguida de una instrucción "reti" en la posición de memoria 0x0040.

    .module crt0gb
    .globl _main
    .globl _vblankISR
    .area _HEADER (ABS)

    .org 0x0040

    call _vblankISR
    reti

    .org 0x0100

    nop
    jp init
    ...

Esto nos obliga a que tenemos que tener una función llamada "vblankISR" en C (con el atributo "__interrupt" para que guarde todos los registros al entrar y los restaure al salir) que se encargue de servir la interrupción de retrazo vertical (VBlank):

void vblankISR() __interrupt {
    // access VRAM here
    // ...
}

La interrupción de retrazo vertical nos proporciona, además, una base de tiempo razonable, puesto que se ejecuta 60 veces por segundo. Aquí puede verse un ejemplo final de pintado mediante interrupción VBlank que cambia la ubicación de las valdosas una vez por segundo (cada 60 interrupciones):
#include <stdint.h>

#define  IE        *((uint8_t *) 0xFFFF)
#define  LCDC      *((uint8_t *) 0xFF40)
#define  STAT      *((uint8_t *) 0xFF41)
#define  BGP       *((uint8_t *) 0xFF47)
#define  BG_TILES  ((uint8_t *) 0x8800)
#define  BG_DATA   ((uint8_t *) 0x9800)

const uint8_t TILE0[16] = {
    0b01010101, 0b00110011,
    0b01010101, 0b00110011,
    0b01010101, 0b00110011,
    0b01010101, 0b00110011,
    0b01010101, 0b00110011,
    0b01010101, 0b00110011,
    0b01010101, 0b00110011,
    0b01010101, 0b00110011
};

const uint8_t TILE1[16] = {
    0b11111111, 0b00000000,
    0b11111111, 0b00000000,
    0b11111111, 0b00000000,
    0b11111111, 0b00000000,
    0b11111111, 0b00000000,
    0b11111111, 0b00000000,
    0b11111111, 0b00000000,
    0b11111111, 0b00000000
};

const uint8_t TILE2[16] = {
    0b00000000, 0b11111111,
    0b00000000, 0b11111111,
    0b00000000, 0b11111111,
    0b00000000, 0b11111111,
    0b00000000, 0b11111111,
    0b00000000, 0b11111111,
    0b00000000, 0b11111111,
    0b00000000, 0b11111111
};

const uint8_t TILE3[16] = {
    0b11111111, 0b11111111,
    0b11111111, 0b11111111,
    0b11111111, 0b11111111,
    0b11111111, 0b11111111,
    0b11111111, 0b11111111,
    0b11111111, 0b11111111,
    0b11111111, 0b11111111,
    0b11111111, 0b11111111
};

void memcpy(void *dest, void *src, uint16_t n) {
    uint8_t *d = (uint8_t *) dest;
    uint8_t *s = (uint8_t *) src;
    while (n > 0) {
        *d = *s;
        d++;
        s++;
        n--;
    }
}

volatile uint8_t frameCounter;
volatile uint8_t frameIndex;

void vblankISR() __interrupt {
    if (frameCounter == 0) {
        // show tiles
        BG_DATA[0] = 0x80 + frameIndex;
        BG_DATA[1] = 0x80 + ((frameIndex + 1) & 0x03);
        BG_DATA[2] = 0x80 + ((frameIndex + 2) & 0x03);
        BG_DATA[3] = 0x80 + ((frameIndex + 3) & 0x03);
        frameIndex = (frameIndex + 1) & 0x03;
        frameCounter = 60;
    }
    else
        frameCounter--;
}

void main() {
    // disable LCD before accessing VRAM
    while ((STAT & 0x03) != 0x01)
        ;
    LCDC = 0x00;
    // copy tile data to tile data VRAM
    memcpy(BG_TILES, TILE0, 16);
    memcpy(BG_TILES + 16, TILE1, 16);
    memcpy(BG_TILES + 32, TILE2, 16);
    memcpy(BG_TILES + 48, TILE3, 16);
    // disable all LCD interrupts
    STAT = 0x00;
    // enable only vblank interrupt
    frameCounter = 0;
    frameIndex = 0;
    IE = 0x01;
    __asm__ ("ei");
    // configure palette
    BGP = 0b11100100;     // 0b11 for full black, 0b10 for dark gray, 0b01 for light gray and 0b00 for white
    // enable LCD, disable window, background tile data at 0x8800, background tile indices at 0x9800, obj not displayed, background display on
    LCDC = 0x81;
    // halt
    while (1)
        ;
}



Checksum de la cabecera

El valor del byte de la posición de memoria 0x014D de la ROM del cartucho se calcula mediante un algoritmo de suma de control entre los bytes 0x0134 y 0x14C (ambos inclusive) de la misma ROM. El código para calcular este valor es el siguiente:

#include <iostream>
#include <iomanip>

using namespace std;

int main() {
    cin.ignore(0x0134);
    uint8_t x = 0;
    for (int i = 0x0134; i <= 0x014C; i++)
        x = x - ((uint8_t) cin.get()) - 1;
    cout << "0x" << hex << setw(2) << setfill('0') << ((int) x) << endl;
    return 0;
}

Una vez generada una ROM se realiza este cálculo y el valor generado debe ser colocado en la posición 0x014D de la propia ROM. Como se puede ver, los campos que afectan al valor de esta suma de control no dependen del código principal de la ROM con lo que no será un valor que se modifique con frecuencia.

Proceso de compilación

A modo de resumen, el proceso de compilación sería entonces el siguiente:
# ensamblamos crt0gb.s
/opt/sdcc/bin/sdasgb -o crt0gb.rel crt0gb.s
# compilamos main.c
/opt/sdcc/bin/sdcc -msm83 -c -o main.rel main.c
# enlazamos para generar la ROM "casi-final" indicando que la RAM está en 0xC000
/opt/sdcc/bin/sdcc -msm83 --data-loc 0xC000 --no-std-crt0 -o main.ihx crt0gb.rel main.rel
/opt/src/Hex2bin-2.5/hex2bin -s 0 -e gb main.ihx
# ahora tenemos la ROM "casi-final" en el fichero "main.gb"
# compilamos en el host el generador de suma de control
g++ -std=c++11 -o header_checksum_calc header_checksum_calc.cc
# lo ejecutamos con el "main.gb" para que nos diga el valor de la suma de control
./header_checksum_calc < main.gb
0x83
# este valor 0x83 es el que debo poner en el fichero crt0gb.s como checksum (dirección 0x014D de la ROM)
# si al poner este valor, se modifica el fichero crt0gb.s, hay que recompilar desde el principio
# Nótese que este valor se calcula a partir de la cabecera, no a partir de toda la ROM, por lo que en circunstancias normales apenas cambia

Como se puede apreciar, uso la utilidad Hex2bin (http://hex2bin.sourceforge.net) para generar el fichero .gb (la ROM) a partir del fichero Inte Hex que genera el compilador SDCC.

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

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

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