Programación Z88DK: Z88DK v1.8 y SP1 (SPLIB3)

Desde la última entrega de nuestro curso de Z88DK ha llovido mucho, y hay bastantes novedades que contar. La principal de ellas es que el 9 de Marzo del 2008 se liberó la versión 1.8 de Z88DK.

Una de las novedades más destacables de la versión 1.7 fue la integración de SPLIB en z88dk, rebautizando a splib3 como SP1. En nuestro anterior artículo comenzamos a utilizar SP1 a través de ejemplos que mostraban la instalación del compilador y la utilización de esta librería de funciones para juegos.

En este artículo mostraremos los diferentes módulos de SP1 y otras bibliotecas interesantes de Z88DK para su utilización en la creación de programas y juegos.

SP1 es una librería de sprites por software (puesto que el Spectrum no dispone de chip hardware para el tratamiento de Sprites), que como el lector sabe, está diseñada para minimizar la cantidad de dibujados de bloques gráficos en pantalla.

SP1 es la nueva versión de la antigua librería SPLIB. En esta versión (SPLIB3), la biblioteca se divide en diferentes módulos para que el programador pueda elegir cuáles de ellos desea usar y cuales no. Hasta ahora, si un programador quería usar las funciones de teclado o de modo IM2 de interrupciones de SPLIB, se veía obligado a incluir la biblioteca completa, con la consiguiente pérdida de memoria, espacio “ejecutable” y tiempo de carga. Ahora, es posible incluir o no cada módulo individualmente.

A continuación se detallan algunas de las librerías incluídas con Z88DK. Algunas son parte del compilador desde sus inicios, y otras son, como ya hemos comentado, diferentes módulos de la antigua SPLIB2, ahora integrada con el nombre de SP1.

Tipos Abstractos de Datos

  • Fichero de cabecera a incluir: #include <atd.h>
  • Librería a enlazar: -ladt.

Esta librería incluye funciones genéricas para algunos tipos abstractos de datos, como Listas Enlazadas (Linked Lists), Pilas (Stacks), y Colas (Queues). Por el momento, estos tipos de datos son dinámicos (se reserva y libera memoria con su uso), aunque están previstas también implementaciones estáticas. Otros tipos de datos (árboles, buffers circulares, etc.) serán implementados en el futuro.

Veamos algunas funciones de ejemplo (en este caso, de Pilas / Stacks) que muestran “el aspecto” de la librería:

adt_StackCreate();
adt_StackDelete();
adt_StackPush();
adt_StackPop();
adt_StackPeek();
adt_StackCount();

La Página de documentación de libadt muestra detallados ejemplos de uso de la librería.

Peticiones de memoria

  • Fichero de cabecera a incluir: #include <malloc.h> e #include <balloc.h>
  • Librería a enlazar: -lmalloc y -lballoc.

Esta librería incluye funciones para pedir y liberar memoria dinámicamente, en lugar de utilizar arrays vacíos estáticos (que aumentan el tiempo de carga).

Veamos algunas funciones de ejemplo:

mallinit();
calloc();
malloc();
free();
realloc();

La Página de documentación de malloc y balloc muestra detallados ejemplos de uso de la librería.

Modo 2 de Interrupciones

  • Fichero de cabecera a incluir: #include <im2.h>
  • Librería a enlazar: -lim2.

Esta librería incluye funciones de gestión del modo 2 de Interrupciones. Estas funciones nos permitirán enlazar la ejecución de código con el modo 2 de interrupciones, para así poder implementar temporizadores y funciones sincronizadas con IM2.

Algunas de las funciones de que provee esta librería son:

im2_init();
im2_InstallISR();
im2_EmptyISR();
im2_CreateGenericISR();

La Página de documentación de im2.h muestra información detallada sobre los modos de interrupciones y ejemplos de uso de la librería.

Teclado, joystick y ratón

  • Fichero de cabecera a incluir: #include <input.h>.
  • Librería a enlazar: No es necesaria.

La biblioteca input.h incluye las funciones necesarias para la lectura del teclado en nuestros juegos y programas. Algunas de las funciones más útiles dentro de esta librería son las siguientes:

in_GetKeyReset();
in_GetKey();
in_InKey();
in_LookupKey();
in_KeyPressed();
in_WaitForKey();
in_WaitForNoKey();
in_Pause();
in_Wait();
in_JoyKeyboard();

Por otra parte, input.h también contiene todas las funciones necesarias para acceder a ratón y joystick, con funciones como las siguientes. Concretamente, la anterior función in_JoyKeyboard() emula la lectura de los joysticks con el mismo formato que la lectura de teclas, con in_LookupKey(). Para hacer uso de la emulación de joystick, será necesario incluir el fichero de cabecera <spectrum.h>.

Como muestra, el siguiente ejemplo tomado de la documentación de la librería input, que combina el uso de in_JoyKeyboard() con las llamadas a in_LookupKey():

#include <input.h>
#include <spectrum.h>
 
// example for ZX Spectrum target which supplies
// the following device specific joystick functions:
// in_JoyKempston(), in_JoySinclair1(), in_JoySinclair2()
 
uchar choice, dirs;
void *joyfunc;                // pointer to joystick function
char *joynames[] = {          // an array of joystick names
   "Keyboard QAOPM",
   "Kempston",
   "Sinclair 1",
   "Sinclair 2"
};
struct in_UDK k;
 
// initialize the struct_in_UDK with keys for use with the keyboard joystick
 
k.fire  = in_LookupKey('m');
k.left  = in_LookupKey('o');
k.right = in_LookupKey('p');
k.up    = in_LookupKey('q');
k.down  = in_LookupKey('a');
 
// print menu and get user to select a joystick
 
printf("You have selected the %s joystick\n", joynames[choice]);
switch (choice) {
   case 0 : joyfunc = in_JoyKeyboard; break;
   case 1 : joyfunc = in_JoyKempston; break;
   case 2 : joyfunc = in_JoySinclair1; break;
   default: joyfunc = in_JoySinclair2; break;
}
 
...
 
// read the joystick through the function pointer
 
dirs = (joyfunc)(&k);
if (dirs & in_FIRE)
   printf("pressed fire!\n");
 
...

Librería de Sonido

  • Fichero de cabecera a incluir: #include <sound.h>.
  • Librería a enlazar: No es necesaria.

Esta librería de sonido contiene funciones muy básicas de reproducción sonora, así como algunos “efectos especiales” estándar.

bit_open();
bit_close();
bit_click();
bit_fx(N);
bit_fx2(N);
bit_fx3(N);
bit_fx4(N);
bit_synth();
bit_beep();
bit_frequency();
bit_play();

Más información sobre sound.h en la página oficial de la librería.

Librería de Sprites SP1

  • Fichero de cabecera a incluir: #include <sprites/sp1.h>.
  • Librería a enlazar: -lsp1.

La librería sprites/SP1 contiene todo el código dedicado a Sprites de la antigua SPLIB: creación de sprites, movimiento, borrado, etc.

Algunas de las funciones que se detallan a continuación ya fueron mostradas en nuestro anterior entrega, incluyendo ejemplos de uso:

sp1_CreateSpr();
sp1_AddColSpr();
sp1_DeleteSpr();
sp1_MoveSprAbs();
sp1_MoveSprRel();
sp1_TileEntry();
sp1_PrintAt();
sp1_GetTiles();
sp1_PutTiles();
sp1_ClearRect();

Más información sobre SP1 en la página de SP1 en el wiki de Z88DK.

Esta biblioteca debe compilarse desde los fuentes de Z88DK para poder utilizarla. Esto se hace entrando en el directorio sp1 y ejecutando el comando make sp1-spectrum:

$ cd $Z88DK
$ cd libsrc/sprites/software/sp1/
$ make sp1-spectrum

Librería general "spectrum.h"

  • Fichero de cabecera a incluir: #include <spectrum.h>.
  • Librería a enlazar: No es necesaria.

La librería spectrum.h contiene funciones varias, como acceso directo a Joystick y Ratón, identificar el modelo de Spectrum que ejecuta nuestro programa, detectar la existencia de periféricos conectados al Spectrum, funciones de cinta (save/load), cambio de color del borde, cálculo de direcciones de atributos, etc.

A continuación se muestran algunas de las funciones que podremos utilizar mediante la inclusión de spectrum.h:

zx_type();
zx_model();
zx_soundchip();
zx_kempston();
zx_basemem();
tape_save();
tape_load_block();
tape_save_block();
in_JoyFuller();
in_JoyKempston();
in_JoySinclair1();
in_JoySinclair2();
in_MouseAMXInit();
in_MouseAMX();
zx_border();
zx_attr();
zx_screenstr();
x_cyx2saddr(); (y derivados)

La biblioteca define también gran variedad de constantes para su uso en nuestros programas, como colores (BLACK, BLUE, RED, MAGENTA, etc.), identificadores de los tokens del BASIC, etc.

Se puede consultar la página de spectrum.h para más información.

Otras librerías

Otras bibliotecas de las que podemos hacer uso:

  • Funciones de E/S: stdio.h.
  • Funciones de reloj y tiempo: time.h.
  • Funciones de acceso al puerto serie: rs232.h.
  • Implementación del algoritmo A*: algorithm.h.
  • Funciones de cadena: string.h.

Puede encontrarse la documentación de cada una de ellas en la página principal de Z88DK.

Una de las cosas más interesantes de Z88DK es que nos permite utilizar ensamblador en-línea dentro de nuestro código en C. Gracias a esto, podemos identificar las rutinas más críticas en necesidades de velocidad (por ejemplo, rutinas gráficas, de sonido, etc), y reescribirlas en ensamblador si es necesario.

Esto permite acelerar el ciclo de desarrollo y depuración, ya que es posible inicialmente escribir el programa o juego íntegramente en C (simplificando mucho la estructura general del programa), para después pasar a convertir en ensamblador, una a una, aquellas rutinas importantes y críticas.

El código en lenguaje ensamblador se inyecta dentro de nuestro código C con la directiva asm.

asm("halt");

Para incluir más de una línea de código ensamblador consecutiva, se recurre a las directivas #asm y #endasm. El siguiente código vacía el contenido de la pantalla:

#asm
   ld hl, 16384
   ld a, 0
   ld (hl), a
   ld de, 16385
   ld bc, 6911
   ldir
#endasm

La principal utilidad será, habitualmente implementar el código ensamblador en funciones C para llamarlas desde otras partes de nuestro programa:

void BORDER_BLACK( void )
{
  #asm
     ld c, 254
     out (c), a
     ld hl, 23624
     ld a, 0
     ld (hl), a
  #endasm
}

Pero para poder aprovechar la integración entre C y ASM, necesitamos conocer los siguientes mecanismos:

  • Creación y referencia de etiquetas ASM.
  • Acceso a variables de C desde bloques ASM.
  • Definición de variables en ASM utilizables desde C.
  • Lectura desde bloques ASM de los parámetros pasados a las funciones.
  • Devolución de valores desde bloques ASM llamados como funciones.

A continuación se detalla la forma de realizar esto desde Z88DK.

Si tenemos que realizar rutinas medianamente largas, necesitaremos utilizar etiquetas para las estructuras condicionales. A continuación se muestra un ejemplo de declaración de una etiqueta y la referencia a la misma:

#asm
   LD B, 8
 
.mirutina_loop               ; la etiqueta lleva punto delante
 
   LD A, (HL)         
   (...)
   LD C, A
   AND 7              
   JR Z, mirutina_loop       ; la referencia a la etiqueta, no.
 
#endasm

Como puede verse, las etiquetas se definen con un punto delante y se referencian sin el punto.

Nótese que hemos utilizado el nombre de la rutina dentro de la etiqueta. Esto es así porque las etiquetas son 2 globales y no podemos llamar a 2 etiquetas de igual forma en 2 funciones diferentes. Por eso, en vez de “loop”, hemos usado “mirutina_loop”, de forma que en una segunda rutina usaremos “mirutina2_loop”.

Dentro de los bloques de ASM podemos acceder a las variables globales definidas en el código de C. Esto se mediante el símbolo de subrayado antes del nombre de la variable:

char borde;
 
void BORDER( void )
{
#asm
   (...)
   // Leer variable:
   ld hl, 23624
   ld a, (_borde)
   ld (hl), a
 
   // Escribir variable:
   ld (_borde), a
 
   // Escribir variable (forma 2):
   ld hl, _borde
   ld a, (hl)
 
#endasm
}

Es de vital importancia, a la hora de leer y escribir valores en variables, que respetemos el tipo de variable en que estamos escribiendo. Si estamos tratando de modificar una variable de 1 sólo byte (apuntada por HL), utilizaremos una operación del tipo LD (HL), A para que, efectivamente, se escriba un sólo byte en la posición de memoria apuntada por la variable. Si escribimos más de un byte, estaremos afectando al byte siguiente al de la variable en cuestión, con lo que modificaremos el valor de la siguiente variable o bloque de código en memoria.

Recuerda para esto que:

  • signed y unsigned char → 1 byte.
  • signed y unsigned short → 2 bytes.
  • signed y unsigned int → 2 bytes.
  • signed y unsigned char * → 2 bytes (es una dirección de memoria).

Finalmente, cabe destacar que sólo se pueden acceder a variables globales: no se podrá acceder desde esta forma a variables definidas dentro de las funciones, puesto que son locales y están localizadas en la pila. Tampoco se podrá acceder de esta forma a los parámetros pasados a las funciones.

Es decir, los siguientes ejemplos no son válidos:

void BORDER( unsigned char borde )
{
   char varlocal;
 
#asm
   ld a, (_varlocal)  // MAL
   (...)
   ld hl, 23624
   ld a, (_borde)     // MAL
   ld (hl), a
#endasm
}

También es posible definir “variables” y bloques de datos dentro de lo que es el “binario ejecutable” del programa, y refenciar a ellos después desde ensamblador. Esto es habitual a la hora de utilizar “variables temporales” o de almacenamiento en rutinas ensamblador cuando tenemos necesidades de almacenamiento que hacen a los registros insuficientes.

Como no vamos a referenciar las variables desde C, no tenemos que utilizar el carácter de subrayado antes del nombre de la variable.

Recuerda, a la hora de guardar los datos dentro de las etiquetas que hacen referencia a ellas, que debes guardar el número de bytes adecuado para no machacar datos o código que vaya tras ellos:

#asm
   ld a, (hl)
   ld (valor_temp), a     ; guardamos un byte (A)
   inc hl
 
   ld c, (hl)
   inc hl
   ld b, (hl)
   inc hl                 ; construimos BC como un word
   ld (valor_int), bc     ; guardamos un word (2 bytes)
 
   (...)
 
   ret
 
 valor_temp  defb  0
 valor_int   defw  0
#endasm

Acabamos de ver cómo acceder en los bloques de ASM a variables definidas desde C. A continuación veremos el proceso inverso: definiremos variables (incluso bloques de datos contiguos) dentro de bloques de ASM que después podrán ser referenciadas desde código C.

Para ello, definimos al principio del código C las variables tal y como las referenciaremos desde C. Es importante indicar el tipo de dato adecuado (char, int, o char *):

extern unsigned char caracter;
extern unsigned int dato_int;
extern unsigned char sprite0[];

Después, en otra zona del programa (normalmente al final del código o en un fichero .c/.h aparte), se define el dato o array de datos al que referencian las variables que hemos indicado como extern. Para ello utilizaremos las directivas DEFB, teniendo en cuenta que el tipo de la variable C nos indica la longitud que debe tener el dato.

#asm
._caracter
        DEFB 52
 
._dato_int
        DEFB 0, 0
 
._sprite0
        DEFB 199,56,131,124,199,56,239,16
        DEFB 131,124,109,146,215,40,215,40
        (...etc...) 
#endasm

En realidad, dentro del bloque #asm sólo estamos definiendo etiquetas a direcciones de memoria (igual que pueda ser la de un bucle) que coincidirán, para el compilador, con las variables (que también son etiquetas a direcciones) definidas como externs del mismo nombre.

Hemos visto que no es posible acceder directamente desde los bloques #asm a las variables locales de una función, así como a los parámetros de llamada, ya que en realidad están almacenados en la pila. Pero aunque no se pueda acceder de forma directa a estos valores, sí que podemos acceder a ellos leyéndolos mediante la ayuda del puntero de pila SP.

Sin utilizar la pila, la mejor forma de pasar parámetros a las funciones es usar algún conjunto de variables globales que utilicemos en las funciones:

// Definimos unas cuantas variables globales que usaremos
// como parametros para nuestras funciones que usen ASM:
 
char auxchar1, auxchar2, auxchar3, auxchar4;
int  auxint1, auxint2, auxint3, auxint4;
 
// A la hora de llamar a una función, lo haríamos así:
 
auxchar1 = 10;
auxchar2 = 20;
MiFuncion();
 
// De este modo, la función podria hacer lo siguiente:
void MiFuncion( void )
{
#asm
     ld a, (_auxchar1)
     ld b, a
     ld a, (_auxchar2)
     (etc...)
#endasm
}

Aunque este método es fáctible (y rápido), resulta más legible el evitar la utilización de este tipo de variables (que haría complicada la creación de funciones recursivas o anidadas). Para ello, utilizaremos el sistema de paso de argumentos basado en la pila.

En C (y en otros lenguajes de programación) los parámetros se insertan en la pila en el orden en que son leídos. La subrutina debe utilizar el registro SP (mejor dicho, una copia) para acceder a los valores apilados en orden inverso. Estos valores son siempre de 16 bits aunque las variables pasadas sean de 8 bits (en este caso ignoraremos el byte que no contiene datos, el segundo).

Veamos unos ejemplos:

  int jugador_x, jugador_y;
 
  jugador_x = 10;
  jugador_y = 200;
  Funcion( jugador_x, jugador_y );
  (...)
 
 
//------------------------------------------------
int Funcion( int x, int y )
{
 
#asm
   ld hl, 2
   add hl, sp          ; Ahora SP apunta al ultimo parametro metido
                       ; en la pila por el compilador (el valor de Y)
   ld c, (hl)
   inc hl
   ld b, (hl)
   inc hl              ; Ahora BC = y
 
   ld e, (hl)
   inc hl
   ld d, (hl)
   inc hl              ; Ahora, DE = x
 
   ; ahora hacemos lo que queramos en asm
   ; utilizando DE y BC, que son los valores
   ; pasados como X e Y
 
#endasm
}

No tenemos que preocuparnos por hacer PUSH y POP de los registros para preservar su valor dado que C lo hace automáticamente antes y después de cada #asm y #endasm.

El problema es que conforme crece el número de parámetros apilados, es posible que tengamos que hacer malabarismos para almacenarlos, dado que no podemos usar HL (es nuestro puntero a la pila en las lecturas). Veamos el siguiente ejemplo con 3 parámetros, donde tenemos que usar PUSH para guardar el valor de DE y EX DE, HL para acabar asociando el valor final a HL:

int Funcion( int x, int y, int z )
{
 
#asm
   ld hl, 2             
   add hl, sp           ; Ahora SP apunta al ultimo parametro metido
                       ; en la pila por el compilador (valor de Z)
   ld c, (hl)
   inc hl
   ld b, (hl)
   inc hl              ; Ahora BC = z
 
   ld e, (hl)
   inc hl
   ld d, (hl)
   inc hl              ; Ahora, DE = y
 
   push de             ; Guardamos DE
 
   ld e, (hl)
   inc hl
   ld d, (hl)
   inc hl              ; Usamos DE para leer el valor de x
 
   ex de, hl           ; Ahora cambiamos x a HL
   pop de              ; Y recuperamos el valor de y en DE 
 
   ; (ahora hacemos lo que queramos en asm)
 
#endasm
}

La manera de leer bytes (char) pulsados en C es de la misma forma que leemos una palabra de 16 bits, pero ignorando la parte alta. En realidad, como la pila es de 16 bits, el compilador convierte el dato de 8 bits en uno de 16 (rellenando con ceros) y pulsa este valor:

int Funcion( char x, y )
{
 
#asm
   ld hl,2             
   add hl,sp           ; Ahora SP apunta al ultimo parametro metido
                       ; en la pila por el compilador (y)
 
   ld a, (hl)          ; Aquí tenemos nuestro dato de 8 bits (y)
   ld b, a
   inc hl
   inc hl              ; La parte alta del byte no nos interesa
 
   ld a, (hl)          ; Aquí tenemos nuestro dato de 8 bits (x)
   ld c, a
   inc hl
   inc hl              ; La parte alta del byte no nos interesa
 
   ; (ahora hacemos lo que queramos en asm)
 
#endasm
}

En ocasiones, es posible que incluso tengamos que utilizar variables auxiliares de memoria para guardar datos. En este caso, utilizamos memoria adicional pero evitamos el uso de la pila.

int Funcion( int x, int y, char z )
{
 
 #asm
   ld hl, 2             
   add hl, sp          ; Ahora SP apunta al ultimo parametro metido
                       ; en la pila por el compilador (Z)
 
   ld c, (hl)
   inc hl
   ld b, (hl)
   inc hl              ; Ahora BC = y
   ld (valor_y), bc    ; nos lo guardamos, BC libre de nuevo
 
   ld c, (hl)
   inc hl
   ld b, (hl)
   inc hl 
   ld (valor_x), bc    ; Nos lo guardamos, BC libre de nuevo
 
   ld a, (hl)
   ld (valor_z), a     ; Nos guardamos el byte
   inc hl
   inc hl              ; La parte alta del byte no nos interesa
 
   (ahora hacemos lo que queramos en asm)
   RET 
 
 valor_x  defw  0
 valor_y  defw  0
 valor_z  defb  0
 #endasm
}

Por contra, para devolver valores no se utiliza la pila (dado que no podemos tocarla), sino que se utiliza el registro HL. Al finalizar la función C, el valor que contenga el registro HL será el devuelto y asignado en la llamada.

En tal caso, cuando realicemos una llamada a una función que contenga un bloque ASM, el valor indicado en HL en el momento de retorno será el devuelto por la misma.

Supongamos pues la siguiente llamada:

valor = MiFuncion( a, b, c);

En este caso, a valor se le asignará el contenido del registro HL. Como tipos de variables de devolución podremos usar int, short o char (en este último caso sólo se asignará la parte baja de HL).

A continuación, como ejemplo, se muestra una función que dadas unas coordenadas X, Y de pantalla (con X entre 0 y 31 e Y entre 0 y 24), devuelve la dirección de memoria donde se puede alterar el atributo correspondiente a dichas coordenadas. El ejemplo muestra cómo recibir variables por la pila, realizar cálculos con ellas, y devolver un valor en HL:

//
// Devuelve la direccion de memoria del atributo de un caracter
// de pantalla, de coordenadas (x,y). Usando la dirección que
// devuelve esta función (en HL, devuelto en la llamada), podemos
// leer o cambiar los atributos de dicho carácter.
//
// Llamada:   valor =  Get_LOWRES_Attrib_Address( 1, 3 );
//
int Get_LOWRES_Attrib_Address( char x, char y )
{
#asm
  ld hl, 2
   add hl, sp                 ; Leemos x e y de la pila
   ld  d, (hl)  ; d = y       
   inc hl                     ; Primero "y" y luego "x".
   inc hl                     ; Como son "char", ignoramos parte alta.
   ld  e, (hl)  ; e = x       
 
   ld h, 0
   ld l, d
   add hl, hl                 ; HL = HL*2
   add hl, hl                 ; HL = HL*4
   add hl, hl                 ; HL = HL*8
   add hl, hl                 ; HL = HL*16
   add hl, hl                 ; HL = HL*32
   ld d, 0
   add hl, de                 ; Ahora HL = (32*y)+x
   ld bc, 16384+6144          ; Ahora BC = offset attrib (0,0)
   add hl, bc                 ; Sumamos y devolvemos en HL
#endasm
}

La intención con este artículo final del curso de Z88DK era cerrar un conjunto de contenidos básicos y necesarios a la hora de desarrollar aplicaciones y juegos con este fantástico compilador cruzado de C. En la anterior entrega vimos su instalación y la utilización de la librería SP1, y en esta se han detallado los diferentes módulos de SP1 y la integración de ASM dentro de nuestros programas en C.

Ambos capítulos, unidos, deberían permitir a un lector con conocimientos de C comenzar su andadura en el desarrollo de aplicaciones o juegos en C para Spectrum, incluyendo la posibilidad de utilizar ensamblador en aquellas partes del programa que lo requieran.