Desarrollo en C de un juego para MSX en soporte de cassette 
Hace tiempo que no tocaba el tema MSX en este blog. Mi página dedicada a MSX llevaba años sin actualizarse y, tras abordar el desarrollo de aplicaciones para el sistema operativo MSXDOS y para ROMs (cartuchos) de MSX, me faltaba el soporte más extendido y proletario de todos: el cassette. El cassette fue el soporte más extendido y barato para la distribución de juegos tanto para MSX como para otras arquitecturas con las que compartió época, como Spectrum, Amstrad o Commodore 64. A lo largo de esta entrada se abordará el desarrollo de un sencillo juegos Sudoku cargable desde BASIC en cassette.

El formato de cassette

Los cassettes fueron el soporte por antonomasia de los juegos para todos los ordenadores de los 80 (Spectrum, Amstrad, MSX, Commodore, etc.). Tardaban en cargar y eran propensos a errores pero eran baratos y el hardware necesario estaba disponible en cualquier hogar: conectabas la salida de auriculares de un reproductor de cassettes a la entrada de audio del ordenador, escribías el comando de carga en el ordenador y le dabas al "play" en el reproductor. Al cabo de unos minutos tenías el juego cargado en el ordenador.


(imagen extraída de Ebay)

A día de hoy ya no se utilizan cassettes físicos (salvo excepciones muy frikis), siendo el formato .CAS es el más extendido para almacenar datos de cassettes y existiendo multitud de utilidades para convertir dichos ficheros .CAS en ficheros .WAV de sonido listos para ser reproducidos o grabados en un cassette real y también apps para móviles que permiten reproducir ficheros .CAS a través de la salida de auriculares del móvil. Esta última aproximación será la que se usará para probar el juego desarrollado en un MSX real.

Estructura de un fichero CAS

Un fichero .CAS contiene los bytes tal cual estarían almacenados en una cinta de cassette y tienen una estructura muy sencilla y ampliamente documentada.

Cada fichero .CAS contiene, a su vez una colección de ficheros con nombre y datos, que se disponen de forma secuencial a lo largo de la cinta. Hay 3 tipos de ficheros, reconocibles por el MSX según su cabecera:

- Ficheros BASIC (accesibles con comandos CSAVE y CLOAD).

- Ficheros binarios (accesibles con comandos BSAVE y BLOAD).

- Ficheros de texto (accesibles con comandos LOAD/SAVE/OPEN).

Cuando desde el BASIC del MSX se desea ejecutar un juego en cinta, normalmente se usa el comando:
BLOAD "CAS:",R

Y a continuación se le da al "play" en el reproductor de cassette. Este comando carga en la RAM el primer fichero binario que se encuentre en la cinta de cassette y lo ejecuta (lo trata como código), por lo que, para hacer un juego cargable desde cinta de cassette, hay que crear un fichero .CAS con al menos un fichero de tipo binario dentro (que sea el primero o el único). Ese fichero binario será el que el BASIC del MSX cargará en RAM y luego ejecutará.

crt0 para crear un binario de cassette

Un fichero crt0 es un fichero, normalmente escrito en lenguaje ensamblador, encargado de describir la estructura interna de un fichero binario o ejecutable y a veces también es el encargado de realizar labores rutinarias en el arranque (puesta a cero de variables globales, por ejemplo). Esto es así en cualquier arquitectura y todos los compiladores disponen de ficheros "crt0" para todas las arquitecturas que soportan.

En este caso se utilizará el SDCC, un compilador de C con licencia GPL ampliamente utilizado para dispositivos pequeños y especializado en arquitecturas de 8 y 16 bits.

Como en otros proyectos de este blog donde se ha utilizado el SDCC, compilarlo desde Linux es muy sencillo:
$ ./configure --prefix=/ruta/de/instalacion/sdcc --disable-pic14-port --disable-pic16-port
$ make
$ make install

La cabecera de un fichero binario para que vaya dentro de un .CAS de MSX tiene 7 bytes y es la siguiente:

- 1 byte con el valor 0xFE.

- 2 bytes con la dirección de inicio del código.

- 2 bytes con la dirección final del código.

- 2 bytes con la dirección de arranque del código (donde empezará la ejecución, normalmente la misma que la dirección de inicio, pero podría ser otra).

Esta estructura es la que se traslada al fichero ensamblador del "crt0" que, en este caso, se ha llamado "crt0msx.s":
    .module crt0msx
.globl _main
.globl _vblank_isr
.area _HEADER (ABS)

.org 0x81F9

.db #0xFE
.dw #0x8200
.dw end - 1
.dw #0x8200

init:
; init global variables
call gsinit
; call main function
call _main
inf_loop:
jp inf_loop


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

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

.area _CODE

.area _GSINIT
gsinit::
.area _GSFINAL
ret
end:

Como se puede apreciar, tanto la dirección de inicio como la dirección de arranque están puestas a fuego como 0x8200. Por eso también la cabecera se pone en 0x81F9 (exactamente 7 bytes antes). La RAM en cualquier MSX que tenga 32Kb de RAM o más (en este caso se asume eso) está disponible a partir de la dirección 0x8000 (según el mapa de la RAM del MSX) y se podría haber puesto en el crt0 la dirección de inicio como 0x8000 pero preferí dejar 512 bytes (0x200 bytes) de margen, por si BASIC tenía algún dato al principio de la RAM.

Prueba de concepto

Como prueba de concepto se hace un pequeño programa en C que escribe una cadena de caracteres usando llamadas a la BIOS y cuelga una rutina de servicio de interrupción que escribe caracteres a razón de uno cada 60 interrupciones de retrazo vertical (v-blank).

En el entorno del BASIC de MSX la zona de memoria denominada "H.TIMI" es una zona de memoria de 5 bytes entre las direcciones de memoria 0xFD9F y 0xFDA3 (ambas inclusive) que contiene código que es llamado mediante una instrucción "CALL" cada vez que se produce una interrupción de retrazo vertical (v-blank). El contenido inicial de este buffer de 5 bytes suele ser

C9 XX XX XX XX: siendo C9 el código máquina de la instrucción RET (retorno de subrutina).

Pero, al ser una zona en la RAM, podemos escribirla sin problema y colocar en ella un salto a una rutina de servicio de interrupción propia. Por ejemplo, podemos escribir:

C3 LL HH XX XX: siendo C3 el código máquina de la instrucción JP (salto incondicional) y LL y HH, respectivamente, el byte bajo y el byte alto de la dirección de memoria de nuestra rutina de servicio de interrupción a la que se quiere saltar.

De esta manera podemos colocar un "hook" o "gancho" que se ejecutará en cada interrupción de retrazo vertical y nos permitirá controlar el timing de nuestro juego (saber cuando debemos escribir en la VRAM, acceder al VPD, etc.).
#define  H_TIMI_HOOK  ((volatile uint8_t *) 0xFD9F)

void putchar(char c) {
    __asm__ (
        "call #0x00A2"
    );
}

void putstr(char *s) {
    while (*s != 0) {
        putchar(*s);
        s++;
    }
}

volatile uint8_t counter;
volatile uint8_t counter2;

void vblank_isr(void) {
    counter++;
    if (counter == 60) {
        counter = 0;
        putchar((counter2 & 0x0F) + '0');
        counter2++;
    }
}

void vblank_isr_set(void (*function_ptr)()) {
    H_TIMI_HOOK[0] = 0xC3;    // JP instruction
    H_TIMI_HOOK[1] = (uint8_t) (((uint16_t) function_ptr) & 0x00FF);
    H_TIMI_HOOK[2] = (uint8_t) (((uint16_t) function_ptr) >> 8);
}

void main(void) {
    counter = 0;
    counter2 = 0;
    vblank_isr_set(vblank_isr);
    putstr("\r\nHola desde SDCC\r\n");
    while (1)
        ;
}

Como se puede ver, la función "vblank_isr_set" recibe por parámetros el puntero a una función y escribe en la zona de memoria "H.TIMI" los bytes necesarios para que se invoque a ese puntero cada vez que se produzca un retrazo vertical (v-blank).

Los pasos para generar el fichero .CAS serán los siguientes:
$ /ruta/al/sdcc/bin/sdasz80 -o crt0msx.rel crt0msx.s
$ /ruta/al/sdcc/bin/sdcc -mz80 --std-c23 --stack-auto -c -o main.rel main.c
$ /ruta/al/sdcc/bin/sdcc -mz80 --std-c23 --stack-auto --code-loc 0x8209 --no-std-crt0 -o main.ihx crt0msx.rel main.rel
$ objcopy -I ihex -O binary main.ihx main.bin
$ mkcas.py --name MAIN --addr 0x81F9 --exec 0x8200 main.cas binary main.bin

Nótese que en "crt0msx.s" la cabecera de 7 bytes empieza en 0x81F9 para que el código empiece en 0x8200. A partir de la dirección 0x8200 tenemos las instrucciones:
    call gsinit     ; 3 bytes, inicializar variables globales
call _main ; 3 bytes, llamada a la función "main"
inf_loop:
jp inf_loop ; 3 bytes, si la función "main" regresa, nos quedamos en un bucle infinito

Este código ocupa 9 bytes y, por eso le decimos al compilador en la línea de comandos que el código compilado debe alojarlo a partir de la dirección 0x8209 (opción "--code-loc").

"objcopy" es una utilidad que se puede encontrar en cualquier Linux (es una utilidad estándar para convertir ficheros binarios entre diferentes formatos) mientras que la utilidad "mkcas.py" es una utilidad desarrollada por Juan J. Martínez que está disponible en su servidor Git y que permite crear un fichero CAS a partir de una colección de ficheros para cassette (recordar que un fichero .CAS es internamente una colección de ficheros binarios, BASIC o de texto). Esta utilidad "mkcas.py" está escrita en Python y se encarga de incluir las cabeceras .CAS necesarias descritas aquí. En este caso se utiliza para construir el "main.cas" a partir del "main.bin" generado por el compilador.



Esta captura se corresponde con la ejecución de la prueba de concepto en el emulador OpenMSX, configurado con la ROM del Philips NMS 8245 y ejecutado de la siguiente manera:
$ openmsx -machine Philips_NMS_8245 -cassetteplayer main.cas


Sudoku

Como proyecto más allá de una simple prueba de concepto, se plantea el desarrollo de un sencillo juego de tipo Sudoku con muy pocas pantallas:



Al igual que en otros proyectos de juegos anteriores, cada pantalla tiene tres funciones asociadas (en este caso son funciones normales de C, no es C++, por lo que la orientación a objetos es simulada):

- void on_load(struct inter_screen_data_t *isd): Se invoca al entrar en la pantalla.

- struct screen_t *on_vblank(uint16_t key_mask, struct inter_screen_data_t *isd): Se invoca en cada retrazo vertical. key_mask es la máscara de teclas que están pulsadas en ese instante en el teclado.

- void on_unload(struct inter_screen_data_t *isd): Se invoca al salir de la pantalla.

Al igual que en proyectos anteriores, el bucle principal sólo invoca las funciones que corresponden en cada momento según qué pantalla esté cargada:

volatile bool vblank;

void vblank_isr(void) {
    vblank = true;
}

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

int main(void) {
    // init vpd and VRAM
    .
    .
    .
    // init vblank isr
    vblank = false;
    vblank_isr_set(vblank_isr);
    // init screens
    volatile struct inter_screen_data_t isd;
    isd.level_index = 0;
    strcpy(isd.password, "000000");
    isd.title_screen = screens;
    isd.password_screen = screens + 1;
    isd.board_screen = screens + 2;
    isd.end_screen = screens + 3;
    const struct screen_t *current_screen = screens;
    current_screen->on_load(&isd);
    uint16_t key_mask = get_key_mask();
    while (1) {
        if (vblank) {
            vblank = false;
            const struct screen_t *next_screen = current_screen->on_vblank(key_mask, &isd);
            if ((next_screen != NULL) && (next_screen != current_screen)) {
                current_screen = next_screen;
                current_screen->on_load(&isd);
            }
            key_mask = get_key_mask();
        }
    }
}

El juego es un sencillo sudoku que se controla con el teclado y que por ahora sólo tiene 3 niveles (se pueden añadir más editando "levels.h" y "levels.c").

Los diferentes niveles están generados usando el proyecto Sudoku++ del desarrollador Oliver Lau. Este proyecto es un generador heurístico de sudokus: compilé el proyecto en Linux y lo ejecuté varias veces para que me generara un buen conjunto de sudokus en ficheros de texto, de los que saqué 3 para ponerlos en el fichero "levels.c".

Carga en un MSX real

En la Play Store de Android hay una utilidad llamada MSX2Cas del desarrollador brasileño Roberto Focosi Jr que permite abrir ficheros .CAS y reproducirlos a través de la salida de audio del móvil.



Los pasos son los siguientes:

- Se instala la app MSX2Cas en el móvil.

- Se pasa el fichero .CAS al móvil.

- Se deja que el MSX arranque en modo BASIC (modo por defecto).

- Se conecta la entrada de audio del MSX a la salida de auriculares del móvil (aquí puede verse el pinout del conector en el MSX para hacer el cable nosotros mismos).

- Se escribe el comando BLOAD "CAS:",R en el MSX (el BASIC se queda esperando a que entren datos por la entrada de audio).

- En la app MSX2Cas del móvil reproducimos el fichero .CAS del juego hasta que cargue y lo ejecute.

- Si la carga falla es probable que sea, o porque el volumen no es el adecuado (demasiado alto o demasiado bajo) o porque hay que invertir la señal de audio (opción disponible en la configuración de la app MSX2Cas).





Toda la información y el código fuente aquí.

Comentarios 

Agregar comentario

Rellene los campos de abajo para dejar su comentario.









Extras (Negrita / Cursiva / URL / Imagen):