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 &#8220;vgm_info&#8221; 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 ] ( 34 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 1.6 / 102 )
Modificación del sonido de un despertador 
A lo largo de esta publicación se aborda una modificación hardware de un reloj despertador para cambiar el sonido que reproduce cuando hace sonar la alarma.

Introducción

Mi hijo recibió por su cumpleaños un despertador con temática Minecraft que le gustó mucho salvo por el sonido que tenía como despertador, que estaba prefijado y era una supuesta grabación "inspirada" en el juego.



Dicha grabación se escucha saturada y con poca calidad por lo que resulta desagradable de escuchar y mi hijo me pidió que intentara cambiarla.

En el siguiente vídeo grabado por un youtuber que hace reviews de este tipo de cosas puede escucharse el sonido original que trae este despertador (instante 4:50 aprox.).



Planteamiento del problema

Se parte de un reloj despertador con una circuitería muy cerrada y no documentada y el objetivo es cambiar el sonido que se escucha cuando suena la alarma, que, de fábrica, es un sonido pregrabado:



Las placas tienen escasa serigrafía y el único integrado visible está "borrado". Lo ideal sería obtener una señal digital que indique la activación de la alarma.

Investigación

El cable plano que une la placa principal con la placa de botones, leds y el chip de audio es un cable plano de 10 hilos que sólo tiene los dos extremos serigrafiados como "vdd" y "gnd".



Si a los 10 cables les quitamos los dos de alimentación de los extremos quedan 8 cables que no se sabe para qué son, sin embargo se intuye, dada la funcionalidad de esa placa, que esos 8 cables están distribuidos de la siguiente manera:

- 6 señales para leer los botones (el reloj tiene una cruceta de 4 botones más 2 botones adicionales).

- 1 señal para controlar los leds blancos (que se usan para la funcionalidad de lámpara).

- 1 señal para controlar la música (cuando suena la alarma).

Tras varias pruebas se verifica que la distribución de las señales es la siguiente.



Y que la señal "alarma" utiliza lógica positiva: se pone a 5 voltios para que suene la alarma y se pone a 0 voltios para apagar la alarma.

Desarrollo de la solución

Teniendo localizado el cable de la señal "alarma" el objetivo ahora es hacer un pequeño montaje que permita reproducir otro tipo de música o sonido en el despertador y para ello se plantea el siguiente esquema eléctrico:



Toda la circuitería del reloj despertador y del DFPlayer Mini funciona con 5 voltios, pero el CH32V003 funciona con 3.3 voltios. En este caso se utiliza una placa "nanoCH32V003" para que pueda alimentarse a 5 voltios de la propia fuente del reloj despertador. El cable de alarma se conecta a PC1 o a cualquier otra entrada del CH32V003 siempre y cuando sea un pin tolerante a 5 voltios (no todos los pines de ese microcontrolador lo son).

El DFPlayer Mini es un módulo de reproducción de MP3 que incluye lector de tarjeta de memoria microSD. Dispone de dos pines dedicados (ADKEY1 y ADKEY2) que, cuando se ponen a masa reproducen, respectivamente, el primer y el quinto MP3 de la tarjeta de memoria. El módulo carece de pines para detener la reproducción pero, como en este caso sólo se precisa que hayan único sonido, se opta por una solución simple:

- Como 1er sonido de la tarjeta de memoria se pone el sonido que queremos que tenga el despertador.

- Como 5o sonido de la tarjeta de memoria se pone un MP3 de un segundo de silencio.

De esta forma poniendo ADKEY1=0 se reproduce el sonido nuevo y poniendo ADKEY2=0 "paramos" la reproducción al reproducir el MP3 de silencio.

Se configuran las salidas PA1 y PA2 del microcontrolador como salidas en colector abierto (open drain): emitiendo un 1 se ponen en alta impedancia y emitiendo un 0 se ponen a masa, que es el comportamiento que se quiere para controlar el DFPlayer Mini:


(imagen extraída de www.prometec.net)

PA1 y PA2 se configuran en open-drain debido a dos razones:

- El DFPlayer Mini ya dispone de resistencias pull-up en las entradas ADKEY1 y ADKEY2.

- El DFPlayer Mini está alimentado a 5 voltios mientras que el CH32V003 lo está a 3.3 voltios, por lo que un "1" del CH32V003 no sería igual que un "1" para el DFPlayer Mini. Usando salidas en open-drain nos aseguramos de que la corriente que circula por los cables PA1 y PA2 proviene del DFPlayer Mini (5 voltios), no del CH32V003 (3.3 voltios).



Código

El código del microcontrolador CH32V003 será el encargado de muestrear el cable "alarm" a intervalos regulares (cada 100 milisegundos). Cuando el cable "alarm" se ponga a 1 el microcontrolador pondrá a 0 (masa) durante un tiempo prefijado la salida conectada a ADKEY1 y cuando el cable "alarm" pase de nuevo a 0, el microcontrolador pondrá el pin conectado a ADKEY2 a 0 (masa) también durante un tiempo prefijado (el correspondiente a reproducir un silencio). La máquina de estados tendrá, por tanto una entrada (la señal del cable "alarm") y dos salidas (que gobiernan las salidas en colector abierto de ADKEY1 y ADKEY2).



El reloj de esta máquina de estados viene dado por el timer del microcontrolador, que tiene un período de 100 milisegundos:

- N_SILENCE es la cantidad de ticks de reloj que debe permanecer a masa la salida "silencio".

- N_SOUND es la cantidad de ticks de reloj que debe permanecer a masa la salida "sonido".

La cantidad total de milisegundos que se ponga a masa cada salida será de N_SILENCE * 100 y de N_SOUND * 100 milisegundos respectivamente.

void AlarmControl::run() {
    Status localStatus = this->status;
    do {
        this->status = localStatus;
        if (localStatus == Status::RESET) {
            this->io.stopTrigSound();
            this->io.stopTrigSilence();
            localStatus = Status::TRIG_SILENCE;
        }
        else if (localStatus == Status::TRIG_SILENCE) {
            this->io.startTrigSilence();
            this->counter = N_SILENCE;
            localStatus = Status::WAIT_SILENCE;
        }
        else if (localStatus == Status::WAIT_SILENCE) {
            this->counter--;
            if (this->counter == 0) {
                this->io.stopTrigSilence();
                localStatus = Status::WAIT_1;
            }
        }
        else if (localStatus == Status::WAIT_1) {
            if (this->io.getAlarm())
                localStatus = Status::TRIG_SOUND;
        }
        else if (localStatus == Status::TRIG_SOUND) {
            this->io.startTrigSound();
            this->counter = N_SOUND;
            localStatus = Status::WAIT_SOUND;
        }
        else if (localStatus == Status::WAIT_SOUND) {
            this->counter--;
            if (this->counter == 0) {
                this->io.stopTrigSound();
                localStatus = Status::WAIT_0;
            }
        }
        else if (localStatus == Status::WAIT_0) {
            if (!this->io.getAlarm())
                localStatus = Status::TRIG_SILENCE;
        }
    } while (localStatus != this->status);
}


La máquina de estados se implementa en la clase "AlarmControl" que decibe en su construcción una referencia a un objeto que debe heredar de "AlarmControlIO".

    class AlarmControlIO {
        public:
            virtual bool getAlarm() = 0;
            virtual void startTrigSound() = 0;
            virtual void stopTrigSound() = 0;
            virtual void startTrigSilence() = 0;
            virtual void stopTrigSilence() = 0;
    };

    class AlarmControl {
        protected:
            AlarmControlIO &io;
            enum class Status { RESET, TRIG_SILENCE, WAIT_SILENCE, WAIT_1, TRIG_SOUND, WAIT_SOUND, WAIT_0 };
            Status status;
            uint32_t counter;
            static const uint32_t N_SILENCE = 4;
            static const uint32_t N_SOUND = 4;
        public:
            AlarmControl(AlarmControlIO &alarmControlIO) : io(alarmControlIO), status(Status::RESET) { };
            void run();
    };


En el fichero "main.cc" se define una clase "MyListener" que tiene un objeto de tipo "AlarmControl" como propiedad y que hereda de:

- "TimerListener" (lo que la obliga a incluir la función miembro "timerExpired()" que se ejecutará cada vez que se desborde el contador de SysTick cada 100 milisegundos).

- "AlarmControlIO" (lo que la obliga a incluir las funciones miembro "bool getAlarm()" para leer el estado de la señal "Alarm" y las otras cuatro funciones "void startTrigSound()", "void stopTrigSound()", "void startTrigSilence()" y "void stopTrigSilence()" para controlar los pines PA1 y PA2 que son los que gobiernan el reproductor MP3 DFPlayer Mini).

En la función miembro "timerExpired()" se invoca, a su vez, a la función miembro "run()" del objeto "AlarmControl" para que se vaya iterando la máquina de estados cada 100 milisegundos.

void MyListener::timerExpired() {
    this->alarmControl.run();
}

bool MyListener::getAlarm() {
    return (GPIOC_INDR & (((uint32_t) 1) << 1)) ? true : false;     // read PC1
}

void MyListener::startTrigSound() {
    GPIOA_OUTDR &= ~(((uint32_t) 1) << 1);      // PA1 = 0
}

void MyListener::stopTrigSound() {
    GPIOA_OUTDR |= (((uint32_t) 1) << 1);      // PA1 = high z
}

void MyListener::startTrigSilence() {
    GPIOA_OUTDR &= ~(((uint32_t) 1) << 2);      // PA2 = 0
}

void MyListener::stopTrigSilence() {
    GPIOA_OUTDR |= (((uint32_t) 1) << 2);      // PA2 = high z
}

int main() {
    interruptInit();
    MyListener myListener;
    Timer::init(myListener, 100_msForTimer);
    while (true)
        asm volatile ("wfi");
}


En la función "main()" simplemente se declara un objeto de tipo "MyListener" (la construcción de este objeto también incluye la construcción del objeto "AlarmControl" debido a que es una propiedad del primero), se inicializa el Timer y nos quedamos en bucle infinito de "wfi" (para consumir poca energía entre tick y tick de la máquina de estados).

Consideraciones particulares entorno al microcontrolador CH32V003

El CH32V003 es un RISC-V muy barato y muy limitado, al contrario que el GD32VF103 que se ha utilizado hasta ahora para otros proyectos con microcontrolador, el CH32V003 tiene las siguientes características:

- 16 Kb de memoria flash para programa.

- 2 Kb de memoria SRAM.

- El controlador de interrupciones no es el estándar CLIC, sino un controlador propietario (llamado PFIC) que se encuentra documentado en el manual de referencia del microcontrolador.

- El núcleo es un "QingKeV2", un RISC-V de perfil RV32EC. E = Embedded (16 registros de propósito general en lugar de los 32 del perfil RV32I) y C = Compressed (acepta instrucciones comprimidas de 16 bits, una especie de equivalente al modo "thumb" de los Cortex-M de ARM).

- Un "SysTick" integrado en el núcleo parecido al que tienen los Cortex-M de ARM.

En la sección soft hay disponible un pequeño proyecto de blinker (ch32v003-pfic-blinker) que utiliza el controlador de interrupciones de este microcontrolador junto con ese "systick" para hacer parpadear un led. Nótese los flags que se pasan al compilador:
-march=rv32eczicsr -mabi=ilp32e

Y la forma en que se habilitan las interrupciones en "interrupt.cc", que son diferentes a como se hace en el GD32VF103.

Tostar el microcontrolador

Para tostar el microcontrolador la mejor opción es pillar un programador WCH-LinkE del propio fabricante (es muy barato, a mi me costó menos de 10 ¤) ya que el protocolo de depuración y tostado es propietario del fabricante (aunque está totalmente documentado y hay proyectos en curso para no depender de ese programador hardware en particular).

El software para acceder al WCH-LinkE que utilicé es el programa "minichlink" (un sub proyecto dentro del repositorio https://github.com/cnlohr/ch32v003fun). Basta con hacer "make" en la carpeta "minichlink" dentro de ese repositorio y se genera el ejecutable "minichlink" que nos permite tostar el CH32V003:
./minichlink -w /ruta/al/main.bin 0x08000000


Montaje y resultados finales

A continuación puede verse cómo se realizaron las conexiones al conector del cable plano para extraer los 5 voltios, masa y la señal de alarma.



Cómo se realizó el montaje del prototipo con el microcontrolador y el DFPlayer Mini en una protoboard externa.



Y el paso del circuito de la protoboard a la PCB que se alojará dentro del reloj despertador:





A continuación un vídeo de demostración con el nuevo sonido (que ya no es la música de Minecraft).



El sonido que se ha puesto como sonido de alarma es el típico de los despertadores de toda la vida (que era el que quería mi hijo) y se ha descargado de https://pixabay.com/sound-effects/031974-30-seconds-alarm-72117/ (Pixabay permite uso libre de los sonidos que se descarguen de su web mientras no se revendan).

Todo el código en la seción soft.

[ añadir comentario ] ( 1041 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 2.8 / 1543 )
Desarrollo de una miniconsola de videojuegos portátil (4): snake 
En esta cuarta entrega de esta miniserie sobre el desarrollo de la GabrielBoy, se abordará el diseño y desarrollo del tercero de los juegos incluidos: un snake, al más puro estilo de los que incluían los antiguos móviles Nokia.

Mecánica del juego

Tenemos una cuadrícula de 30x30 posiciones por la que va deambulando una serpiente que tenemos que dirigir en pos de su comida (la comida va apareciendo aleatoriamente por el tablero). Cada vez que la serpiente come una pieza crece una unidad en tamaño y el jugador debe evitar chocarse tanto contra los bordes del tablero como contra él mismo (a medida que la serpiente crece se hace más complicado evitar chocarnos contra nosotros mismos).

Es un juego muy conocido por antiguos propietarios de móviles de la marca Nokia, ya que esteos terminales los solían traer de serie, además, el juego no tiene fin en el sentido de que nunca "se gana", simplemente hay que tratar de sobrevivir lo máximo posible sin chocarnos contra los bordes o contra nosotros mismos a medida que comemos y crecemos en longitud.

Diseño de la pantalla

La única pantalla que tiene el juego está gestionada por la clase "SnakeMainScreen" (en la carpeta "games/snake"). Consiste en un tablero central, que alberga 30 x 30 huecos de 2 x 2 pixels cada uno. Con estas dimensiones tenemos un tablero que ocupa 60 x 60 pixels y que se coloca en el centro de la pantalla. A este tablero se le añade un borde de 2 pixels de anchura en todo su perímetro, por lo que al final tenemos que el tablero ocupa un total de 64x64 pixels. Los huecos de los lados son de 32 pixels a izquierda (donde se coloca un dibujo estático) y de 32 pixels a la derecha (donde va el contador de frutas comidas "fru").



Desarrollo

Cada posición del tablero de 30x30 es un byte que podrá tener uno de los siguientes valores:

- 0: para indicar que esta posición está vacía.

- SnakeDirection::TO_UP: para indicar que hay serpiente en esta posición y que el siguiente punto de la serpiente (en dirección a su cabeza) está en la posición de arriba.

- SnakeDirection::TO_DOWN: igual pero indicando que el siguiente punto de la serpiente (en dirección a su cabeza) está en la posición de abajo.

- SnakeDirection::TO_LEFT: ídem hacia la izquierda.

- SnakeDirection::TO_RIGHT: ídem hacia la derecha.

- FOOD: para indicar que en esa posición hay una pieza de comida.

Aparte de estos datos en el tablero, se mantienen las coordenadas de la cabeza y de la cola de la serpiente y no es necesario almacenar su longitud. Esta forma de modelar la serpiente en el tablero nos permite simplificar tanto el movimiento como el crecimiento de la misma:

- Para movernos basta con hacer avanzar la cabeza en la dirección actual o la indicada por la última pulsación de los botones de dirección, y la cola en la dirección que indique la propia celda del tablero (recordemos que los valores SnakeDirection::TO_XXXX indican el siguiente elemento de la serpiente en dirección a su cabeza).

- Cada vez que la serpiente come (la cabeza se encuentra con comida) lo único diferente que se hace es que la cola NO avance, por lo que, de forma efectiva, estamos haciendo crecer la serpiente.

Como se puede ver, no es necesario guardar ni controlar el tamaño de la serpiente puesto que la cola siempre es capaz de "encontrar su camino" (aunque se realicen zigzags en bloque y en celdas adyacentes del tablero).

A continuación el código de la función miembro "SnakeMainScreen::advance", que es la encargada de gestionar el avance de la serpiente. Como se puede ver en el código en caso de que la cabeza de la serpiente se encuentre con comida la única diferencia es que la cola no avanza.

void SnakeMainScreen::advance(Collide &c) {
    c = Collide::NO;
    // check direction
    int16_t newHeadX = this->headX + this->dir->x;
    int16_t newHeadY = this->headY + this->dir->y;
    if ((newHeadX < 0) || (newHeadX >= BOARD_WIDTH) || (newHeadY < 0) || (newHeadX >= BOARD_HEIGHT))
        c = Collide::YES;
    else {
        // no collision to borders
        uint16_t oldHeadOffset = (this->headY * BOARD_WIDTH) + this->headX;
        uint8_t oldHeadValue = this->board[oldHeadOffset];
        uint16_t newHeadOffset = (newHeadY * BOARD_WIDTH) + newHeadX;
        uint8_t newHeadValue = this->board[newHeadOffset];
        if ((newHeadValue == SnakeDirection::TO_UP) || (newHeadValue == SnakeDirection::TO_DOWN) || (newHeadValue == SnakeDirection::TO_LEFT) || (newHeadValue == SnakeDirection::TO_RIGHT))
            c = Collide::YES;
        else if (newHeadValue == 0) {
            uint16_t tailOffset = (this->tailY * BOARD_WIDTH) + this->tailX;
            uint8_t tailValue = this->board[tailOffset];
            int16_t newTailX, newTailY;
            this->calculateNewTail(tailValue, newTailX, newTailY);
            this->board[tailOffset] = 0;
            this->drawBoardPosition(this->tailX, this->tailY, 0);
            this->tailX = newTailX;
            this->tailY = newTailY;
        }
        else if (newHeadValue == FOOD) {
            int16_t foodX, foodY;
            this->allocateNewFood(foodX, foodY);
            this->drawBoardPosition(foodX, foodY, 1);
            this->fruitCounter++;
            this->display.drawNumber(100, 32, this->fruitCounter, 3, Display::ShowZeros::YES);
        }
        this->board[oldHeadOffset] = this->dir->to;
        this->board[newHeadOffset] = this->dir->to;
        this->drawBoardPosition(newHeadX, newHeadY, 1);
        this->display.notifyFrameBufferChanged();
        this->headX = newHeadX;
        this->headY = newHeadY;
    }
}


Conclusiones y cierre de la serie

La consola GabrielBoy ha sido una primera aproximación al problema de implementar una consola portátil desde cero. La parte software es la parte que menos me ha costado ya que es un mundo al que estoy muy acostumbrado, mientras que la parte más compleja para mi ha sido planificar el espacio, hacer prototipos, las soldaduras, los diseño y la impresión 3D de la caja, etc.

Quiero dar las gracias a Aristóbulo, por su paciencia a la hora de enseñarme a usar el FreeCAD y por ayudarme en el diseño y la impresión de la caja de la consola.



Todo el código fuente y los diseños están disponibles en la sección soft.

[ añadir comentario ] ( 3219 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 2.9 / 1830 )
Desarrollo de una miniconsola de videojuegos portátil (3): shooter 
En esta tercera entrenga de esta miniserie sobre el desarrollo de la GabrielBoy, se abordará el diseño y desarrollo del segundo juego: un shooter en 3D. Consiste en un entorno 3D simulado utilizando técnicas de raycasting en el que somos un tirador que debe recorrer el escenario y disparar a todos los items para pasar de nivel. Por limitaciones propias del hardware los muros en el juego son negros (se pintan los bordes pero no se rellenan), mientras que los items o "enemigos" son blancos (pixels rellenos) y fijos (no se mueven).

Mecánica del juego

Se trata de shooter 3D simplificado: hay que buscar todos los items blancos y dispararles con A para que desaparezcan. En el momento que hemos terminado con todos los items de un nivel, nos vamos al siguiente nivel, y así sucesivamente. En el juego no puedes "morir" simplemente vas cambiado de niveles y cuando terminas el último vuelves a empezar.

Diseño de la pantalla

La única pantalla que tiene el juego está gestionada por la clase TanksMainScreen (en la carpeta games/tanksfp). El dibujado de la escena 3D se realiza en el centro de la pantalla (64x64 pixels). Las zonas de los lados se utilizan para indicar cuantos items o enemigos quedan por abatir en el nivel actual y el número del nivel.



Renderizado de la escena usando raycasting

La técnica del raycasting, la utilizada en muchos de los juegos 3D de los años 90 y principios de los 2000 para dibujar escenas en 3D, se basaba realmente en el cálculo de colisiones de vectores bidimensionales. Para cada columna de la pantalla se calcula un vector 2D (rayo) que va desde el jugador hasta la escena pasando por ese punto de la pantalla.

Si el vector choca con algún muro u objeto, se calcula la distancia a la que choca dicho vector y, por semejanza de triángulos con respecto a la altura del muro u objeto contra el que ha chocado el rayo, se calcula la altura que debe tener esa sección del objeto en la coordenada x por la que ha pasado el rayo.

En este caso:

$$\frac{altura\ proyección}{distancia\ a\ la\ pantalla} = \frac{altura\ objeto}{distancia\ al\ objeto}$$

Se define un mundo en dos dimensiones en el que los muros y los items son segmentos y el jugador se mueve en el plano 2D:
const Level TanksMainScreen::LEVELS[] = {
    {     // nivel 0
/*
     +---+
cada |   | es un cuadrado de 10x10 unidades del mundo, P es el jugador
     +---+

    +---+---+---+---+---+---+---+---+
    |                               |
    +   P-->            +           +
    |                   |           |
    +                   +---+---+---+
    |                               |
    +---+---+---+---+               +
    |               |               |
    +               +               +
    |               |               |
    +               +               +
    |                               |
    +---+---+---+---+---+---+---+---+
*/
        80,    // width = 80 (integer)
        60,    // height = 60 (integer)
        8,    // 8 segments
        {
            {{0, 0}, {5242880, 0}},    // scene segments (Q16.16 fixed point)
            {{5242880, 0}, {5242880, 3932160}},
            {{5242880, 3932160}, {0, 3932160}},
            {{0, 3932160}, {0, 0}},
            {{0, 1966080}, {2621440, 1966080}},
            {{2621440, 1966080}, {2621440, 3276800}},
            {{3276800, 655360}, {3276800, 1310720}},
            {{3276800, 1310720}, {5242880, 1310720}}
        },
        {655360, 655360},    // (10, 10) (Q16.16 fixed point)
        0,            // player at (10, 10) looking with ANGLE[0]
        2             // 2 collectable/shootable items
    },

    // ... other levels

};

El algoritmo de pintado &#8220;lanza&#8221; los 64 rayos correspondientes las 64 columnas de la ventana de la escena 3D (el recuadro de 64x64 pixels que se dibuja en el centro del display LCD) y para cada rayo, se calcula la intersección del mismo con cada uno de los segmentos del mundo y los enemigos (del nivel).

Cada segmento del mundo tendrá dos coordenadas 2D asociadas, mientras que cada rayo estará compuesto por la coordenada del jugador más un vector director unitario apuntando a la columna del display correspondiente. En cada frame, las coordenadas del jugador no cambian, lo que cambia es el vector director (el rayo).

El algoritmo grosso modo sería el siguiente:

para cada coordenada x entre -31 y +31 (se asume que 0 es el centro de la pantalla)
    rayo = vector unitario que va desde el jugador y apunta a la columna x
    distanciaColision = infinita
    segmentoAPintar = ninguno
    para cada segmento s del nivel
        calcular las posible colisión entre el rayo y s
        si colisiona y (distancia < distanciaColision) entonces
            distanciaColision = distancia
            segmentoAPintar = s
        fin si
    fin para
    si (segmentoAPintar != ninguno) entonces
        alturaEnPantalla = ALTURA * distanciaAPantalla / distanciaColision
        pintar en la columna x un segmento vertical de tamaño alturaEnPantalla
    en caso contrario
        pintar en la columna x un punto en el centro (horizonte)
    fin si
fin para


Cálculo de las colisiones

Cada segmento $s$ del mundo tendrá dos coordenadas 2D asociadas:
$$
\left( \left( x_{s1}, y_{s1} \right), \left( x_{s2}, y_{s2} \right) \right)
$$
Mientras que cada uno de los 64 rayos que se "lanzan" será un vector de la forma
$\left( \left( x_r , y_r \right), \left( x_{rd} , y_{rd} \right) \right)$ siendo $\left( x_r , y_r \right)$ las coordenadas del jugador en el mapa y $\left( x_{rd} , y_{rd} \right)$ el vector director unitario que apunta hacia el pixel.

Si definimos los puntos del segmento usando ecuaciones paramétricas, tenemos que:
$$
x = x_{s1} + t \left( x_{s2} - x_{s1} \right)\\
y = y_{s1} + t \left( y_{s2} - y_{s1} \right)
$$
Siendo $0 \leq t \leq 1$, de tal manera que:
$$
t = 0 \Rightarrow \left( x, y \right) = \left( x_{s1}, y_{s1} \right)\\
t = 1 \Rightarrow \left( x, y \right) = \left( x_{s2}, y_{s2} \right)
$$
Mientras que si definimos los puntos a lo largo del rayo que trazamos desde el jugador hasta la columna de la pantalla tenemos que:
$$
x = x_{r} + u x_{rd}\\
y = y_{r} + u y_{rd}
$$
Siendo $0 \le u$ y $u$ la distancia desde el jugador hasta $\left( x, y \right)$. A continuación definimos $d_{xs} = x_{s2} - x_{s1}$ y $d_{ys} = y_{s2} - y_{s1}$ y despejamos:
$$
x = x_{s1} + t d_{xs} = x_{r} + u x_{rd}\\
y = y_{s1} + t d_{ys} = y_{r} + u y_{rd}\\
t = \frac{x_{r} + u x_{rd} - x_{s1}}{d_{xs}}\\
u = \frac{y_{s1} + t d_{ys} - y_{r}}{y_{rd}}
$$
Por tanto:
$$
u = \frac{y_{s1} + \frac{x_{r} + u x_{rd} - x_{s1}}{d_{xs}} d_{fs} - y_{r}}{y_{rd}}\\
u y_{rd} = y_{s1} + \frac{x_{r} d_{ys}}{d_{xs}} + u \frac{x_{rd} d_{ys}}{d_{xs}} - \frac{x_{s1} d_{ys}}{d_{xs}} - y_{r}\\
u y_{rd} - u \frac{x_{rd} d_{ys}}{d_{xs}} = y_{s1} + \frac{x_{r} d_{ys}}{d_{xs}} - \frac{x_{s1} d_{ys}}{d_{xs}} - y_{r}\\
u \left( y_{rd} - \frac{x_{rd} d_{ys}}{d_{xs}} \right) = y_{s1} + \frac{x_{r} d_{ys}}{d_{xs}} - \frac{x_{s1} d_{ys}}{d_{xs}} - y_{r}\\
u = \frac{y_{s1} + \frac{x_{r} d_{ys}}{d_{xs}} - \frac{x_{s1} d_{ys}}{d_{xs}} - y_{r}}{y_{rd} - \frac{x_{rd} d_{ys}}{d_{xs}}}
$$
Multiplicando numerador y denominador por $d_{xs}$:
$$
u = \frac{d_{xs} y_{s1} + x_{r} d_{ys} - x_{s1} d_{ys} - y_{r} d_{xs}}{y_{rd} d_{xs} - x_{rd} d_{ys}}
$$
De esta forma ya tenemos calculado $u$ que será la distancia entre el jugador y la recta que contiene el segmento $s$. si $u < 0$ significará que el segmento está detrás del jugador.

Ahora con $u$ calculado, podemos sustituir su valor en:
$$
t = \frac{x_{r} + u x_{rd} - x_{s1}}{d_{xs}}
$$
Lo que nos dará el valor de $t$. Si $t < 0$ o $t > 1$ significará que el rayo no corta con el segmento. Nótese que si $y_{rd} d_{xs} - x_{rd} d_{ys} = 0$ significará que el rayo y la recta que contiene el segmento no se cortan (son paralelos) y debe ser tenido en cuenta para evitar una división entre 0:

TanksMainScreen::Intersection TanksMainScreen::getIntersection(const Segment &seg, const Vector &ray, const fixedpoint_t minRayT, fixedpoint_t &segT, fixedpoint_t &rayT) {
    fixedpoint_t dxs = seg.p2.x - seg.p1.x;
    fixedpoint_t dys = seg.p2.y - seg.p1.y;
    if ((dxs != 0) || (dys != 0)) {
        fixedpoint_t denRayT = (ray.dir.y * dxs) - (ray.dir.x * dys);
        if (denRayT != 0) {
            rayT = ((dxs * seg.p1.y) + (ray.p.x * dys) - (seg.p1.x * dys) - (ray.p.y * dxs)) / denRayT;
            if (rayT >= minRayT) {
                if (dxs != 0)
                    segT = (ray.p.x + (rayT * ray.dir.x) - seg.p1.x) / dxs;
                else
                    segT = (ray.p.y + (rayT * ray.dir.y) - seg.p1.y) / dys;
                if ((segT >= 0) && (segT <= fixedpoint_t::get(1)))
                    return Intersection::ONE_POINT;
            }
        }
    }
    return Intersection::NO_POINT;
}


Optimizaciones y datos precalculados

Todos los cálculos de precisión decimal se realizan utilizando aritmética de punto fijo en formato Q16.16 (enteros de 32 bits con 16 bits para la parte entera y 16 bits para la parte fraccionaria) y ayudándonos de la sobrecarga de operadores para facilitar la escritura de código y la mantenibilidad del mismo.

class fixedpoint_t {
    public:
        int32_t v;
        fixedpoint_t(int32_t x = 0) : v(x) { };
        inline fixedpoint_t &operator = (const int32_t &x) { this->v = x << 16; return *this; };
        inline fixedpoint_t operator + (const fixedpoint_t &x) const { fixedpoint_t ret; ret.v = this->v + x.v; return ret; };
        inline fixedpoint_t operator - (const fixedpoint_t &x) const { fixedpoint_t ret; ret.v = this->v - x.v; return ret; };
        inline fixedpoint_t operator - () const { fixedpoint_t ret; ret.v = -(this->v); return ret; };
        inline fixedpoint_t operator * (const fixedpoint_t &x) const { fixedpoint_t ret; ret.v = (((int64_t) this->v) * ((int64_t) x.v)) >> 16; return ret; };
        inline fixedpoint_t operator / (const fixedpoint_t &x) const { fixedpoint_t ret; ret.v = (((int64_t) this->v) << 16) / ((int64_t) x.v); return ret; };
        inline bool operator == (const fixedpoint_t &x) const { return (this->v == x.v); };
        inline bool operator != (const fixedpoint_t &x) const { return (this->v != x.v); };
        inline bool operator < (const fixedpoint_t &x) const { return (this->v < x.v); };
        inline bool operator > (const fixedpoint_t &x) const { return (this->v > x.v); };
        inline bool operator <= (const fixedpoint_t &x) const { return (this->v <= x.v); };
        inline bool operator >= (const fixedpoint_t &x) const { return (this->v >= x.v); };
        inline fixedpoint_t operator += (const fixedpoint_t &x) { this->v += x.v; return *this; };
        inline fixedpoint_t operator -= (const fixedpoint_t &x) { this->v -= x.v; return *this; };
        inline int32_t getIntegerPart() { return this->v >> 16; };
        inline static fixedpoint_t get(int32_t x) { fixedpoint_t ret; ret.v = x << 16; return ret; };
};

Además existen dos puntos clave en el código donde son necesarios cálculos trigonométricos:

1. El jugador está definido por sus coordenadas y por un vector unitario que apunta a "donde está mirando". Dicho vector coincide con el vector del rayo para la columna 0 de la pantalla por lo que cada rayo será una rotación del vector "hacia donde estoy mirando" y las rotaciones se deben calcular mediante senos y cosenos, así que lo que se hace en este caso es generar unas tablas precalculadas con los senos y los cosenos de los diferentes ángulos necesarios para calcular los 64 rayos de la pantalla. De hecho no hacen falta 64 senos y cosenos, basta con 32, puesto que la pantalla es simétrica.

2. Para que el jugador gire, se hace una rotación de su vector director alrededor de la coordenada del propio jugador y dicha rotación se realiza también aprovechando tablas precalculadas de senos y cosenos sólo para un cuadrante (son simétricos cambiándoles el signo para los otros tres cuadrantes de la circunferencia goniométrica).

Para ayudarnos en la generación de datos precalculados se hacen dos scripts:

- calculate_dir_vector.sh NUM_ÁNGULOS: Genera una tabla precalculada con los senos y los cosenos de NUM_ÁNGULOS en el intervalo $\left[ 0 , \frac{\pi}{2} \right)$. Los valores generador en formato de punto fijo Q16.16 (directamente "copiables y pegables" en el código C++).

- calculate_display_angles.sh DIST_TO_CENTER DISPLAY_WIDTH: Genera una tabla precalculada de 32 registros. Cada registro contiene un ángulo en radianes (no se usa en el código), el seno de ese ángulo, el coseno de ese ángulo y la distancia desde el jugador hasta el punto de la pantalla (el valor "distanciaAPantalla" necesario para calcular correctamente la altura de los objetos proyectados). DIST_TO_CENTER es la distancia desde el jugador hasta el centro de la pantalla en unidades del mundo y DISPLAY_WIDTH es la anchura de la pantalla en unidades del mundo.

A continuación se puede ver cómo queda el código que calcula el trazado de rayos de la pantalla a partir del vector del jugador:

const AngleAndDistance TanksMainScreen::DISPLAY_ANGLES_AND_DISTANCES[32] = {
    // precalculated vector of angles and distances to display from player
    // DISPLAY_ANGLES_AND_DISTANCES(i).angle       = the angle in radians from center os display of pixel located at center +/- i  (not used in code)
    // DISPLAY_ANGLES_AND_DISTANCES(i).cosineAngle = cos(angle)
    // DISPLAY_ANGLES_AND_DISTANCES(i).sineAngle   = sin(angle)
    // DISPLAY_ANGLES_AND_DISTANCES(i).distance    = the distance in world units from player to the pixel in the display located at center +/- i
    {0, 65536, 0, 327680},                     // ./calculate_display_angles.sh 5 15          distance from player to center of display = 5 world units, display width = 15 world units
    {3069, 65464, 3068, 328039},
    {6126, 65249, 6117, 329116},
    {9155, 64897, 9126, 330904},
    {12146, 64413, 12077, 333390},
    {15087, 63806, 14954, 336559},
    {17967, 63088, 17743, 340393},
    {20778, 62269, 20432, 344869},
    {23512, 61363, 23011, 349962},
    {26163, 60382, 25473, 355646},
    {28726, 59340, 27815, 361893},
    {31199, 58248, 30034, 368675},
    {33579, 57119, 32129, 375962},
    {35866, 55963, 34102, 383726},
    {38060, 54791, 35956, 391939},
    {40161, 53610, 37694, 400572},
    {42172, 52428, 39321, 409600},
    {44094, 51253, 40842, 418996},
    {45931, 50088, 42262, 428736},
    {47684, 48940, 43587, 438799},
    {49358, 47810, 44822, 449161},
    {50955, 46704, 45974, 459803},
    {52480, 45622, 47048, 470705},
    {53934, 44567, 48049, 481851},
    {55322, 43539, 48982, 493223},
    {56647, 42540, 49852, 504807},
    {57912, 41570, 50664, 516587},
    {59120, 40629, 51421, 528551},
    {60274, 39717, 52129, 540687},
    {61378, 38834, 52790, 552983},
    {62433, 37979, 53408, 565429},
    {63442, 37152, 53987, 578016}
};

...

void TanksMainScreen::calculateRay(Vector &ray, fixedpoint_t &distToDisplay, int32_t x) {     // x = -31..31
    ray = this->player;
    fixedpoint_t cosine = 1;
    fixedpoint_t sine = 0;
    if (x < 0) {
        cosine = DISPLAY_ANGLES_AND_DISTANCES[-x].cosineAngle;
        sine = DISPLAY_ANGLES_AND_DISTANCES[-x].sineAngle;
        distToDisplay = DISPLAY_ANGLES_AND_DISTANCES[-x].distance;
    }
    else {
        cosine = DISPLAY_ANGLES_AND_DISTANCES[x].cosineAngle;
        sine = -DISPLAY_ANGLES_AND_DISTANCES[x].sineAngle;
        distToDisplay = DISPLAY_ANGLES_AND_DISTANCES[x].distance;
    }
    ray.rotate(cosine, sine);
}

Y cómo queda el código que calcula el cambio del vector del jugador cuando éste se gira:

const Angle TanksMainScreen::ANGLES[16] = {              // 16 angles (cosines and sines) for first quadrant (other quadrant values are calculated changing cos/sin signs)
    {65536, 0},                                          // ./calculate_dir_vector.sh 16
    {65220, 6423},
    {64276, 12785},
    {62714, 19024},
    {60547, 25079},
    {57797, 30893},
    {54491, 36409},
    {50660, 41575},
    {46340, 46340},
    {41575, 50660},
    {36409, 54491},
    {30893, 57797},
    {25079, 60547},
    {19024, 62714},
    {12785, 64276},
    {6423, 65220}
};

...

void TanksMainScreen::fillAngle(Angle &a, const uint8_t i) {
    if (i < 16)
        a = ANGLES[ i ];
    else if ((i >= 16) && (i < 32)) {
        a.cosine = -ANGLES[i - 16].sine;
        a.sine = ANGLES[i - 16].cosine;
    }
    else if ((i >= 32) && (i < 48)) {
        a.cosine = -ANGLES[i - 32].cosine;
        a.sine = -ANGLES[i - 32].sine;
    }
    else if (i >= 48) {
        a.cosine = ANGLES[i - 48].sine;
        a.sine = -ANGLES[i - 48].cosine;
    }
}

...

void TanksMainScreen::rotatePlayer(RotateTo t) {
    if (t == RotateTo::LEFT)
        this->playerAngle = (this->playerAngle + 1) & 0x3F;   // 0..63
    else if (t == RotateTo::RIGHT)
        this->playerAngle = (this->playerAngle + 64 - 1) & 0x3F;   // 0..63
    Angle a;
    fillAngle(a, this->playerAngle);
    this->player.dir.x = a.cosine;
    this->player.dir.y = a.sine;
}


Conclusión y siguiente entrega

El uso de raycasting combinado con el cálculo mediante aritmética de punto fijo permite a un microcontrolador de potencia muy limitada proyectar escenas básicas en 3D en tiempo real y poder disfrutar de una experiencia 3D aunque sea en una pequeña pantalla LCD de 128x64 pixels. En la siguiente entrega de esta serie relacionada con la consola GabrielBoy se abordará el diseño y la implementación del mítico juego Snake.



Todo el código y los diseños están en la sección soft.

[ añadir comentario ] ( 935 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 1620 )
Desarrollo de una miniconsola de videojuegos portátil (2): tetris 
En esta segunda entrega de esta miniserie sobre el desarrollo de la GabrielBoy, se abordará el diseño y desarrollo del primero de los juegos: un tetris. Se parte del diseño original del tetris, que consiste en una cuadrícula de 10x20 posiciones en la que van cayendo piezas que el jugador debe ir colocando buscando que llenen filas enteras. No me pararé en explicar el juego porque todos los conocemos. Así que vamos a ello.

Mecánica del juego

Se trata de un tetris estándar: van apareciendo las piezas por arriba de forma aleatoria, con la cruceta movemos a los lados o hacemos que la pieza baje más rápido y con el botón A rotamos la pieza. Cuando conseguimos hacer una o varias líneas horizontales completas dichas líneas de borran del tablero y aumenta la velocidad de caida en función de la cantidad de líneas eliminadas. El jugador nunca "gana", las fichas siguen cayendo indefinidamente hasta que apaguemos la consola, reiniciemos o la última ficha en caer ya no quepa en el tablero porque este está lleno.

Diseño de la pantalla

La única pantalla que tiene el juego está gestionada por la clase TetrisMainScreen (en la carpeta games/tetris). Dibuja un tablero central, que alberga 10 x 20 huecos de 3 x 3 pixels cada uno (cada &#8220;cuadrado&#8221; del tetris es un bloque de 3 x 3 pixels). Con estas dimensiones tenemos un tablero que ocupa 30 x 60 pixels y que se coloca en el centro de la pantalla. Los huecos de los lados son de 49 pixels a izquierda y de 49 pixels a la derecha (49 + 30 + 49 = 128 pixels de anchura de la pantalla LCD).



El hueco de la izquierda se utiliza para indicar la siguiente figura que va a caer mientras que el hueco de la derecha se utiliza para indicar el nivel por el que se va: cada 5 filas eliminadas se sube de nivel y aumenta un 5% la velocidad de caida de las figuras. El nivel máximo es el 9 y a partir de ese nivel ya no se aumenta la velocidad de caida.

Mecánica interna

El código no trabaja con el framebuffer de la pantalla, sino que trabaja con una matriz de 10 x 20 enteros en la que cada elemento puede tener los siguientes valores:

0: hueco libre.

1: hueco ocupado por suelo.

2: hueco ocupado por una pieza que está aún cayendo

    static const int32_t BOARD_WIDTH = 10;
    static const int32_t BOARD_HEIGHT = 20;
    static const int32_t BOARD_SIZE = BOARD_WIDTH * BOARD_HEIGHT;
    uint8_t board[BOARD_SIZE]            __attribute__ ((aligned(4)));

Se define el tablero con el atributo "aligned(4)" de GCC para garantizar que el compilador aloja dicha variable en una dirección de memoria múltiplo de 4 bytes (32 bits), de esta manera las operaciones de inicialización y rrecorrido del tablero puede optimizarse un poco más. Las figuras están definidas en un array constante (en ROM) de 7 elementos y cada elemento del array (cada figura) es una matriz de 4x4 bytes.
class TetrisFigure {
    public:
        static const int32_t MAX_WIDTH = 4;
        static const int32_t MAX_SIZE = MAX_WIDTH * MAX_WIDTH;
        int32_t width;
        int32_t height;
        uint8_t data[MAX_SIZE]  __attribute__ ((aligned(4)));
        TetrisFigure &operator = (const TetrisFigure &other);
        void rotateInto(TetrisFigure &other);
        void rotate();
};

...

const TetrisFigure TetrisMainScreen::FIGURES[7] = {
    {
        4,
        1,
        {
            1, 1, 1, 1,
            0, 0, 0, 0,
            0, 0, 0, 0,
            0, 0, 0, 0
        }
    },
    {
        3,
        2,
        {
            1, 1, 0, 0,
            0, 1, 1, 0,
            0, 0, 0, 0,
            0, 0, 0, 0
        }
    },
    {
        3,
        2,
        {
            0, 1, 1, 0,
            1, 1, 0, 0,
            0, 0, 0, 0,
            0, 0, 0, 0
        }
    },
    {
        3,
        2,
        {
            1, 1, 1, 0,
            0, 0, 1, 0,
            0, 0, 0, 0,
            0, 0, 0, 0
        }
    },
    {
        3,
        2,
        {
            1, 1, 1, 0,
            1, 0, 0, 0,
            0, 0, 0, 0,
            0, 0, 0, 0
        }
    },
    {
        3,
        2,
        {
            0, 1, 0, 0,
            1, 1, 1, 0,
            0, 0, 0, 0,
            0, 0, 0, 0
        }
    },
    {
        2,
        2,
        {
            1, 1, 0, 0,
            1, 1, 0, 0,
            0, 0, 0, 0,
            0, 0, 0, 0
        }
    }
};

La figura que cae es una copia en RAM de la figura correspondiente de ese array, pues puede ser necesario rotarla. La rotación, como siempre es en pasos de 90 grados, se realiza por la técnica de la transposición y a continuación aplicar función espejo vertical u horizontal, y así no hay que hacer cálculos trigonométricos.

Máquina de estados

La máquina de estados consta de 3 estados:

1. NEW_FIGURE (estado inicial): En este estado se coge la figura siguiente y se intenta colocar en la parte superior del tablero para que vaya cayendo:

1.1. Si se puede colocar, se pasa al estado FALLING y se calcula una nueva figura para que sea la p2róxima siguiente".

1.2. Si no se puede colocar porque ya toca con suelo o con figuras anteriores "consolidadas", se pasa al estado GAME_OVER.

2. FALLING: Este es el estado principal del juego, la figura actual va cayendo y en el momento que se detecta que toca contra suelo o con borde inferior del tablero, la figura se convierte en suelo (se "consolida"). Cuando se detecta que se ha "generado suelo nuevo" se recorre el tablero, se eliminan las filas llenas y se comprueba si se debe subir de nivel.

2.1. Si la figura que está cayendo toca suelo, se pasa al estado NEW_FIGURE.

3. GAME_OVER: Por ahora es un estado "muerto". El juego se cuelga intencionadamente y el jugador debe reiniciar la consola si quiere seguir jugando o empezar de nuevo.



Screen *TetrisMainScreen::onUpdate() {
    bool boardChanged = false;
    uint8_t b = this->buttons.getValue();
    LevelChanged levelChanged = LevelChanged::NO;
    if (this->status == St::NEW_FIGURE) {
        this->generateNewRandomFigure();
        this->nextFigureChanged = true;
        this->figureX = this->rnd.getNextValue() % (10 - this->figure.width);
        this->figureY = 0;
        if (this->getFigureValidAt(this->figure, this->figureX, this->figureY)) {
            this->status = St::FALLING;
            this->ticksBetweenMovs = this->initialTicksBetweenMovs;
            boardChanged = true;
        }
        else {
            this->status = St::GAME_OVER;    // game over is a dead state (console must be reseted)
        }
    }
    else if (this->status == St::FALLING) {
        this->ticksBetweenMovs--;
        if ((this->ticksBetweenMovs <= 0) || (b & Buttons::MASK_DOWN)) {
            if (this->getCanMoveFigureTo(Direction::DOWN)) {
                this->figureY++;
            }
            else {
                this->finalizeFigure(levelChanged);
                this->status = St::NEW_FIGURE;
            }
            if (this->ticksBetweenMovs <= 0)
                this->ticksBetweenMovs = this->initialTicksBetweenMovs;
            boardChanged = true;
        }
        else if ((b & Buttons::MASK_A) && this->getCanRotateFigure()) {
            this->figure.rotate();
            boardChanged = true;
        }
        else if ((b & Buttons::MASK_LEFT) && this->getCanMoveFigureTo(Direction::LEFT)) {
            this->figureX--;
            boardChanged = true;
        }
        else if ((b & Buttons::MASK_RIGHT) && this->getCanMoveFigureTo(Direction::RIGHT)) {
            this->figureX++;
            boardChanged = true;
        }
    }
    if (boardChanged) {
        this->updateBoardWithFigure();
        if (levelChanged == LevelChanged::YES)
            this->drawLevelLabel();
        this->drawBoardWithFigureOnFrameBuffer();
        if (this->nextFigureChanged) {
            this->drawNextFigureOnFrameBuffer();
            this->nextFigureChanged = false;
        }
        this->display.notifyFrameBufferChanged();
    }
    return nullptr;
}


Siguiente entrega

En la siguiente entrega se analizará el segundo de los juegos que incluye la consola. Un shooter 3D muy sencillo implementado con la técnica del raycasting



Todo el código y los diseños están en la sección soft.

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

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