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í.
[ añadir comentario ] ( 22 visualizaciones ) | [ 0 trackbacks ] | enlace permanente |




( 3 / 30 )La consola Sega Master System fue una consola de videojuegos de finales de los 80, con un hardware muy de su época: basada en cartuchos, con una cpu Z80, procesador gráfico TMS9918 (derivado de los usados en MSX) y chip de sonido SN76489 (muy de su época: 3 canales de tonos con onda cuadrada más un canal de ruido). A lo largo de este post se desarrollará una pequeña prueba de concepto desde cero en C para genera la ROM de un juego compatible con Sega Master System.
Mapa de memoria y secuencia de arranque
El mapa de memoria de la SMS es muy sencillo, ya que las primeras posiciones de memoria (el vector de reset del Z80 es 0x0000) están en el cartucho, por lo que el cartucho de cada juego es responsable del arranque correcto de la CPU (vector de reset, puntero de pila e interrupciones).
$10000 -----------------------------------------------------------
Paging registers
$FFFC -----------------------------------------------------------
Mirror of RAM at $C000-$DFFF
$E000 -----------------------------------------------------------
8k of on-board RAM (mirrored at $E000-$FFFF)
$C000 -----------------------------------------------------------
16k ROM Page 2, or one of two pages of Cartridge RAM
$8000 -----------------------------------------------------------
16k ROM Page 1
$4000 -----------------------------------------------------------
15k ROM Page 0
$0400 -----------------------------------------------------------
First 1k of ROM Bank 0, never paged out with rest of Page 0
$0000 -----------------------------------------------------------
Aunque la SMS permite paginación de memoria, para ROMs de hasta 32Kb no es necesario y no será tenido en cuenta para este ejemplo. Los puntos clave en una ROM de la SMS son los siguientes:
- Vector de reset en 0x0000 (aquí empieza la ejecución del código)
- En 0x0038 está el vector de interrupción enmascarable: Las interrupciones en la SMS se gestionan con el modo 1 de la CPU, por lo que todas las interrupciones enmascarables (las "normales") van al vector 0x0038. En la SMS se puede configurar el chip gráfico (VDP) para que genere interrupciones cuando se produce un retrazo vertical o cuando se alcance determinada línea horizontal en el pintado.
- En 0x0066 está el vector de interrupción no enmastarable: en el caso de la SMS la interrupción no enmascarable está conectada al botón "pause" de la carcasa de la consola.
- La RAM de la consola empieza en 0xC000 y termina en 0xDFF0.
- En nuestro caso particular, para guardar las partidas, lo habitual es que en cartucho disponga de una RAM no volátil propia a partir de la dirección 0x8000 que se puede mapear en el espacio de direcciones de la CPU configurando unos registros especiales.
- Información sobre el cartucho (su cabecera) se encuentra alojada a partir de la dirección 0x7FF0 de la ROM del cartucho y consta de 16 bytes que indican tipo de ROM, tamaño y una pequeña firma ASCII para identificarla como ROM de SMS.
Aunque formalmente, la RAM termina en 0xDFFF, como dicha RAM tiene un espejo en 0xE000-0xFFFB y las últimas direcciones de memoria 0xFFFC-0xFFFF se usan para el control de la paginación de memoria, se considera que la RAM "termina" antes, en 0xDFF0. Por tanto, como la pila del Z80 crece hacia direcciones bajas de memoria (SP disminuye cuando se hace PUSH y aumenta cuando se hace POP), lo habitual es inicializar el puntero de pila SP a 0xDFF0 y que el compilador coloque las variables globales a partir de 0xC000.
Para la prueba de concepto se utilizará el compilador SDCC que se puede instalar siguiendo las instrucciones indicadas en este post, y que viene acompañado de todos los compiladores y enlazadores necesarios. A continuación se puede ver un típico esqueleto de código de arranque de ROM de Sega Master System en ensamblador:
.module crt0sms
.globl _main
.globl _vblankISR
.globl _nmISR
.area _HEADER (ABS)
.org 0x0000
di
im 1 ; SMS runs in z80 interrupt mode 1
jp init
.org 0x0038
jp _vblankISR
.org 0x0066
jp _nmISR
init:
; init stack pointer
ld sp, #0xDFF0 ; at end of onboard RAM
; init memory mapper
ld hl,#0x0008 ; first page of cartridge RAM at 0x8000
ld (#0xfffc),hl ; [0xfffc] = 0x08, [0xfffd] = 0x00
ld hl,#0x0201
ld (#0xfffe),hl ; [0xfffe] = 0x01, [0xffff] = 0x02
; init global variables
call gsinit
; main function
ei
call _main
inf_loop:
jp inf_loop
; ordering of segments for the linker.
.area _HOME
.area _CODE
.area _INITIALIZER
.area _GSINIT
.area _GSFINAL
.area _CODE
.area _GSINIT
gsinit::
.area _GSFINAL
ret
.area _DATA
.area _INITIALIZED
.area _BSEG
.area _BSS
.area _HEAP
.area _TAIL (ABS)
.org 0x7FF0 ; cartridge "header"
.ascii "TMR SEGA" ; https://www.smspower.org/Development/ROMHeader
.db #0x00, #0x00 ; reserved
.db #0x00, #0x00 ; checksum
.db #0x26, #0x70, #0xA0 ; product code 107026, version 0
.db #0x4C ; SMS export, rom size = 32 Kb
Los pasos para la inicialización son los siguientes:
- Deshabilitar interrupciones
- Poner el modo 1 de interrupciones (vector único en 0x0038).
- Poner el puntero de pila en 0xDFF0
- Configuramos en los registros de paginación (0xFFFC y 0xFFFE) que los primeros 8K de la RAM del cartucho (para guardar partidas) estarán mapeados a partir de la dirección 0x8000.
- Invocamos la función "gsinit", que es proporcionada por el compilador (no la tenemos que escribir nosotros) y es la encargada de inicializar las variables globales que tengamos..
- Invocamos la función "main".
El código anterior asume que se deben definir como mínimo 3 funciones:
void vblankISR(void) __critical __interrupt(0): rutina de servicio de interrupción sólo interrumpible por una interrupción no enmascarable (NMI). En la práctica es una rutina "normal" de interrupción que lo primero que hacer nada más entrar es deshabilitar las interrupciones y volver a habilitarlas antes de salir.
void nmISR(void) __critical __interrupt: rutina de servicio de interrupción no interrumpible y no enmascarable (NMI). Se ejecuta cuando se pulsa en botón "pause" de la consola.
void main(void): el punto de entrada de nuestro código.
Chip gráfico (VDP)
El chip gráfico de la Sega Master System es una versión simplificada del chip gráfico del MSX y sólo soporta un único modo de vídeo. El chip tiene una VRAM (RAM de vídeo) dedicada y separada de la RAM de la CPU. A dicha VRAM se accede mediante operaciones de puerto (instrucciones "in", "out" del Z80). La VRAM está organizada como sigue:
$4000 ---------------------------------------------------------------
Sprite info table: contains x,y and tile number for each sprite
$3F00 ---------------------------------------------------------------
Screen display: 32x28 table of tile numbers/attributes
$3800 ---------------------------------------------------------------
Sprite/tile patterns, 256..447
$2000 ---------------------------------------------------------------
Sprite/tile patterns, 0..255
$0000 ---------------------------------------------------------------
Esta forma de organizar la VRAM no es fija y puede cambiarse escribiendo en registros especiales del VDP aunque es la más habitual en todos los juegos de SMS y la que usaremos para la prueba de concepto.
Lo habitual para asegurarnos de que el VDP está correctamente inicializado es:
- Inicializar sus registros a unos valores por defecto.
- Inicializar toda la VRAM a 0.
A continuación el código utilizado para inicializar el VDP y que se coloca justo antes de llamar "gsinit" (que inicializa las variables globales):
init_vdp_data:
.db #0x04, #0x80, #0x00, #0x81, #0xff, #0x82, #0xff, #0x85, #0xff, #0x86, #0xff, #0x87, #0x00, #0x88, #0x00, #0x89, #0xff, #0x8a
init_vdp_data_end:
...
; init vdp
ld hl, #init_vdp_data
ld b, #init_vdp_data_end - #init_vdp_data
ld c, #0xbf
otir
; clear vram
ld a, #0x00
out (#0xbf), a
ld a, #0x40
out (#0xbf), a
ld bc, #0x4000 ; 16384 bytes of vram
clear_vram_loop:
ld a, #0x00
out (#0xbe), a
dec bc
ld a, b
or c
jp nz, clear_vram_loop
...Para comprender el significado detallado de los datos "init_vdp_data" es mejor recurrir a la documentación del VDP, por ahora nos basta saber que con esos comandos, el VDP queda correctamente inicializado.
Fondo de la pantalla
La imagen en la pantalla de la Sega Master System está compuesta por 32 x 24 baldosas de 8 x 8 pixels cada bandolsa. Cada pixel está definido por 4 bits (máximo 15 colores simultáneos más el color 0 que siempre es transparente o negro). Cada color está definido por una paleta con dos bits por cada componente ("rrggbb").
La forma de definir los colores en las baldosas es algo críptica por la forma en que se definen los bits así que lo mejor es poner un ejemplo. Imaginemos que queremos que una baldosa sea un corazón rojo de bordes blancos y fondo transparente:
color | símbolo | índice en la paleta -------+---------+---------------------- transp | - | 0b0000 (0) rojo | * | 0b0001 (1) blanco | + | 0b0010 (2) -------- -++-++-- +**+**+- +*****+- -+***+-- --+*+--- ---+---- --------
Para este caso, la baldosa quedaría definida de la siguiente manera:
const uint8_t HEART_PATTERN[32] = { // bit // 0 1 2 3 cada byte: msb=pixel más izq, lsb=pixel más derecha 0x00, 0x00, 0x00, 0x00, 0x00, 0x6C, 0x00, 0x00, 0x6C, 0x92, 0x00, 0x00, 0x7C, 0x82, 0x00, 0x00, 0x38, 0x44, 0x00, 0x00, 0x10, 0x28, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
Cada fila de la baldosa está definida por 4 bytes (total 4 x 8 = 32 bytes por baldosa). Y en cada fila el byte 0 tiene los bits 0 de cada pixel, el byte 1 tiene los bits 1 de cada pixel, el tercer byte 2 los bits 2 de cada pixel y el byte 3 los bits 3 de cada pixel. En el ejemplo anterior, el dibujo sólo utiliza 3 colores (0, 1 y 2), por eso los bytes 2 y 3 están a 0 siempre.
En nuestro caso, a partir de una foto y utilizando el Gimp para aplicar el algoritmo de reducción de paleta Floyd-Steinberg, generamos una imagen de 128 x 120 pixels (16 x 15 baldosas) junto con una paleta reducida de 16 colores.
#include <stdint.h> #include "ana.h" __sfr __at (0xBE) VDP_DATA; __sfr __at (0xBF) VDP_ADDRESS; #define PALETTE_OFFSET_TILES 0 #define PALETTE_OFFSET_SPRITES 16 void write_palette(uint8_t offset, const uint8_t *palette) { VDP_ADDRESS = offset; // start at color 0 VDP_ADDRESS = 0b11000000; uint8_t n = 16; while (n > 0) { VDP_DATA = *palette; palette++; n--; } } void write_vram(const uint8_t *src, uint16_t size, uint16_t vram_addr) { VDP_ADDRESS = (uint8_t) (vram_addr & 0x00FF); VDP_ADDRESS = 0b01000000 | ((uint8_t) ((vram_addr >> 8) & 0x3F)); while (size > 0) { VDP_DATA = *src; src++; size--; } } void write_vram_2(const uint8_t *src, uint16_t size, uint16_t vram_addr) { VDP_ADDRESS = (uint8_t) (vram_addr & 0x00FF); VDP_ADDRESS = 0b01000000 | ((uint8_t) ((vram_addr >> 8) & 0x3F)); while (size > 0) { VDP_DATA = *src; VDP_DATA = 0; src++; size--; } } void vblankISR(void) __critical __interrupt(0) { uint8_t vdp_status = VDP_ADDRESS; } void nmISR(void) __critical __interrupt { } void main(void) { __asm__ ("di"); // draw background image write_palette(PALETTE_OFFSET_TILES, ANA_PALETTE); write_vram(ANA_TILE_PATTERNS, 241 * 4 * 8, 0); // 240 + 1 tile patterns --> vram pattern address (0x0000) write_vram_2(ANA_TILE_MAP, 32 * 24, 0x3800); // 32 * 24 tiles --> vram tile map address (0x3800) // enable display VDP_ADDRESS = 0b01100000; VDP_ADDRESS = 0x81; __asm__ ("ei"); while (1) ; }
Compilamos:
$ /opt/sdcc/bin/sdasz80 -o crt0sms.rel crt0sms.s $ /opt/sdcc/bin/sdcc -mz80 -c -o main.rel main.c $ /opt/sdcc/bin/sdcc -mz80 --data-loc 0xC000 --no-std-crt0 -o main.ihx crt0sms.rel main.rel $ objcopy -I ihex -O binary main.ihx main.sms
Y el fichero "main.sms" podemos ahora ejecutarlo con un simulador o incluso pasarlo a un cartucho flash para ejecutarlo en una Sega Master System real:

Sprites
El formato de baldosas descritp es el que se utiliza tanto para el fondo como para los sprites, con el añadido de que los sprites tienen su propia paleta, que puede ser diferente de la paleta de las baldosas del fondo.
La Sega Master System puede manejar hasta 64 sprites por hardware. La tabla de información de sprites ("sprite info table"), alojada a partir de la dirección 0x3F00 de la VRAM es una tabla de 256 bytes que almacena las coordenadas (x, y) de cada sprite y el número de baldosa que se usará para el sprite. Si asumimos que n = 0 ... 63 (0x00 ... 0x3F) tenemos que, dentro de la VRAM:
- 0x3F00 + n: es la coordenada "y" del sprite (un valor de 208 aquí significa que este es el final de la tabla de sprites y ya no hay más sprites que pintar).
- 0x3F80 + (n x 2): es la coordenada "x" del sprite.
- 0x3F81 + (n x 2): índice de la baldosa (0 a 255).
La VRAM permite definir hasta 512 baldosas distintas y mediante el registro 6 del VDP se configura si el índice del sprite hace referencia a las primeras 256 baldosas o a las últimas 256 baldosas de las 512. En nuestro caso (a través de "init_vdp_data") configuramos este registro para que las baldosas de los sprites sean las últimas 256 de todas las 512.
Interrupción vblank
La interrupción vblank es la parte del código donde habitualmente se realiza la escritura o modificación de los sprites, las baldosas, etc. ya que es en ese momento cuando se está produciendo una pausa entre un cuadra y el siguiente en la pantalla .
En nuestro caso colocaremos ahí un sencillo código que hace moverse al sprite a través de la pantalla rebotando en los bordes.
Código de la parte gráfica
A continuación se ve cómo quedaría todo el código de la parte gráfica (acceso al VDP, a la VRAM y la interrupción vblank):
#include <stdint.h> #include "ana.h" __sfr __at (0xBE) VDP_DATA; __sfr __at (0xBF) VDP_ADDRESS; const uint8_t SPRITE_PALETTE[16] = { // red white 0x00, 0x03, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; const uint8_t SPRITE_PATTERN[32] = { // bit // 0 1 2 3 each byte: msb=leftmost pixel, lsb=rightmost pixel 0x00, 0x00, 0x00, 0x00, 0x00, 0x6C, 0x00, 0x00, 0x6C, 0x92, 0x00, 0x00, 0x7C, 0x82, 0x00, 0x00, 0x38, 0x44, 0x00, 0x00, 0x10, 0x28, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; #define PALETTE_OFFSET_TILES 0 #define PALETTE_OFFSET_SPRITES 16 void write_palette(uint8_t offset, const uint8_t *palette) { VDP_ADDRESS = offset; // start at color 0 VDP_ADDRESS = 0b11000000; uint8_t n = 16; while (n > 0) { VDP_DATA = *palette; palette++; n--; } } void write_vram(const uint8_t *src, uint16_t size, uint16_t vram_addr) { VDP_ADDRESS = (uint8_t) (vram_addr & 0x00FF); VDP_ADDRESS = 0b01000000 | ((uint8_t) ((vram_addr >> 8) & 0x3F)); while (size > 0) { VDP_DATA = *src; src++; size--; } } void write_vram_2(const uint8_t *src, uint16_t size, uint16_t vram_addr) { VDP_ADDRESS = (uint8_t) (vram_addr & 0x00FF); VDP_ADDRESS = 0b01000000 | ((uint8_t) ((vram_addr >> 8) & 0x3F)); while (size > 0) { VDP_DATA = *src; VDP_DATA = 0; src++; size--; } } uint8_t sprite_x; uint8_t sprite_y; int8_t sprite_x_inc; int8_t sprite_y_inc; void vblankISR(void) __critical __interrupt(0) { uint8_t vdp_status = VDP_ADDRESS; // apply (sprite_x, sprite_y) coordinates to sprite vram VDP_ADDRESS = 0; VDP_ADDRESS = 0b01000000 | 0x3F; VDP_DATA = sprite_y; VDP_ADDRESS = 0x80; VDP_ADDRESS = 0b01000000 | 0x3F; VDP_DATA = sprite_x; // update (sprite_x, sprite_y) sprite_x += sprite_x_inc; if (sprite_x == (256 - 8)) sprite_x_inc = -1; else if (sprite_x == 0) sprite_x_inc = 1; sprite_y += sprite_y_inc; if (sprite_y == (192 - 8)) sprite_y_inc = -1; else if (sprite_y == 0) sprite_y_inc = 1; } void nmISR(void) __critical __interrupt { } void main(void) { __asm__ ("di"); // draw background image write_palette(PALETTE_OFFSET_TILES, ANA_PALETTE); write_vram(ANA_TILE_PATTERNS, 241 * 4 * 8, 0); // 240 + 1 tile patterns --> vram pattern address (0x0000) write_vram_2(ANA_TILE_MAP, 32 * 24, 0x3800); // 32 * 24 tiles --> vram tile map address (0x3800) // draw sprite write_palette(PALETTE_OFFSET_SPRITES, SPRITE_PALETTE); VDP_ADDRESS = 0; // border color = color 0 of sprite palette VDP_ADDRESS = 0x87; write_vram(SPRITE_PATTERN, 4 * 8, 0x2000); // locate sprite sprite_x = 80; sprite_y = 80; sprite_x_inc = 1; sprite_y_inc = 1; VDP_ADDRESS = (uint8_t) (0x3F00 & 0x00FF); VDP_ADDRESS = 0b01000000 | ((uint8_t) ((0x3F00 >> 8) & 0x3F)); VDP_DATA = sprite_y; VDP_DATA = 208; // end of sprite list (sprite_y = 208) VDP_ADDRESS = (uint8_t) (0x3F80 & 0x00FF); VDP_ADDRESS = 0b01000000 | ((uint8_t) ((0x3F80 >> 8) & 0x3F)); VDP_DATA = sprite_x; VDP_DATA = 0; // tile 0 of sprite tiles // enable display VDP_ADDRESS = 0b01100000; VDP_ADDRESS = 0x81; __asm__ ("ei"); while (1) ; }
Chip de sonido (PSG)
El chip de sonido SN76489 es un chip muy sencillo al que se accede mediante escrituras sucesivas en el registro 0x7F (es de sólo escritura) y en la documentación se explicmuy bien cómo se programa. En nuestro caso, para evitar estar "componiendo" o haciendo música desde cero para la prueba de concepto, se ha optado por recurrir al formato de música VGM ("Video Game Music"), documentado aquí y que es muy fácil de leer y de procesar pues es simplemente un volcado de los valores que deben escribirse en el registro del chip junto con comandos de espera.
Se definen dos funciones:
- void vgm_init(vgm_info *vgm, const uint8_t *file_data): que inicializa una estructura "vgm_info" a partir de los datos de una canción en formato VGM.
- void vgm_tick(vgm_info *vgm): que se encarga de escribir los datos en el chip de sonido con la cadencia indicada por la canción VGM. Esta función debe ser invocada en cada vblank del VDP.
typedef struct { const uint8_t *first_byte; const uint8_t *next_byte; uint16_t wait_counter; } vgm_info; void vgm_init(vgm_info *vgm, const uint8_t *file_data) { uint32_t version = *((uint32_t *) (file_data + 0x08)); if (version < 0x00000150) vgm->next_byte = file_data + 0x40; else { uint32_t data_offset = *((uint32_t *) (file_data + 0x34)); if (data_offset == 0x0000000C) vgm->next_byte = file_data + 0x40; else vgm->next_byte = file_data + data_offset + 0x34; } vgm->first_byte = vgm->next_byte; vgm->wait_counter = 0; } void vgm_tick(vgm_info *vgm) { if (vgm->wait_counter > 0) { vgm->wait_counter--; return; } const uint8_t *p = vgm->next_byte; if (*p == 0x50) { vgm->wait_counter = 0; while (*p == 0x50) { p++; PSG = *p; p++; } } while ((*p == 0x61) || (*p == 0x62) || (*p == 0x63)) { if ((*p == 0x62) || (*p == 0x63)) { vgm->wait_counter++; p++; } else { p++; uint16_t num_samples = *((uint16_t *) p); p += 2; //vgm->wait_counter += num_samples / 882; // convert samples to ticks (requires stdlib because of integer division) // // aproximate num_samples / 882 with num_samples / 768 = num_samples / (256 * 3) // (1 / 3) * 65536 = 21845, so: // num_samples / 768 = ((num_samples / 256) * 21845) / 65536 // num_samples / 768 = ((num_samples >> 8) * 21845) >> 16 // num_samples / 768 = (ns * 21845) >> 24 // num_samples / 768 = (ns * (16384 + 4096 + 512 + 256 + 32 + 4)) >> 24 // num_samples / 768 = (ns * (16384 + 4096 + 512 + 256 + 32 + 4)) >> 24 // num_samples / 768 = ((ns << 14) + (ns << 12) + (ns << 8) + (ns << 5) + (ns << 2) + ns) >> 24 uint32_t aux = num_samples; aux = ((aux << 14) + (aux << 12) + (aux << 8) + (aux << 5) + (aux << 2)) >> 24; vgm->wait_counter = aux; } } while ((*p & 0x70) == 0x70) // wait n + 1 samples, 1 tick = 882 samples, so ignore 0x7X commands p++; if (*p == 0x66) { vgm->wait_counter = 0; vgm->next_byte = vgm->first_byte; } else vgm->next_byte = p; }
Como puede verse, desde la función main se invoca "vgm_init" indicando un puntero a los datos VGM a procesar y se invoca "vgm_tick" en cada interrupción vblank. La única parte algo críptica del código de "vgm_tick" es cuando hay que calcular la cantidad de ticks de espera a partir de los samples, pues la fórmula oficial es $\frac{samples}{882}$ para el caso PAL (50 Hz) y $\frac{samples}{735}$ para el caso NTSC (60 Hz).
En nuestro caso se ha optado por buscar un término medio y calcular los ticks de espera mediante la fórmula $\frac{samples}{768}$ (independientemente de que la consola se sea PAL o NTSC). Esta división entera se ha realizado mediante desplazamientos y sumas para evitar tener que usar la división proporcionada por la librería de SDCC (el Z80 no tiene instrucción de división).
Desde vgmrips.net pueden descargarse cientos de canciones en formato VGM para el chip SN76489.
Conclusiones
La Sega Master System es un sistema potente para su época y muy sencillo de programar comparado con la NES y otras consolas de su generación. El formato de baldosas es quizá lo más críptico que tiene pero en cuanto se le coge el truco, no es tan complicado aunque es mejor recurrir a herramientas externas para ayudarnos en las conversiones de datos. Todo el código puede descargarse de la sección soft.
[ añadir comentario ] ( 1085 visualizaciones ) | [ 0 trackbacks ] | enlace permanente |




( 2.9 / 1962 )A lo largo de este post desgranaremos, paso a paso y desde cero, cómo programar en C para esta mítica consola de mano. Usaremos como base el compilador cruzado SDCC y la documentación que hay disponible.
Instalación del SDCC
Lo primero que hay que hacer es instalar o compilar el compilador SDCC. En caso de que vayamos a compilarlo para Linux o para algún Unix, nos descargaremos el código fuente de http://sdcc.sourceforge.net y compilaremos con:
./configure --prefix=/opt/sdcc --disable-pic14-port --disable-pic16-port
make
make install
De esta forma tendremos instalado el compilador cruzado SDCC (se deshabilitan los targets pic14 y pic16 para evitar la dependencia con "gputils"). En nuestro caso sólo necesitamos los target "z80" y "sm83" (el chip SM83 es la CPU de 8 bits que trae la GameBoy, una variante del Z80 en la que cambian sólo algunas instrucciones).
Documentación sobre el estándar
No es mi intención detallar en este post todos los entresijos de la arquitectura de la GameBoy ni de su modelo de programación. Toda la información al respecto la he extraido básicamente de aquí y de aquí, así como de otras fuentes de fácil acceso en internet. Me centraré en lo principal y necesario para obtener una ROM funcional para GameBoy.
Estructura de la ROM de un cartucho y del resto de memoria
Para empezar nos centraremos en el uso de una ROM básica de 32 Kbytes sin conmutación de bancos de memoria (hay otros tipos de ROM, pero este es el más básico):

Después de arrancar, la ROM de arranque comprueba el logo y el checksum del cartucho insertado y, si es correcto, salta a la dirección de memoria 0x0100, que es donde debe residir la primera instrucción del cartucho. Como se puede apreciar en la estructura del cartucho, en esta zona apenas caben 4 bytes (a partir de 0x0104 vienen los datos del logo de Nintendo, el nombre del juego, la licencia, el checksum, etc.), por lo que lo habitual es colocar ahí una instrucción de salto hacia una dirección donde esté el resto del código: por ejemplo 0x0150 u otra. Aquí puede verse con detalle todo el mapa de memoria de la GameBoy. La zona de los vectores de interrupción, que está antes de 0x0100, la veremos más adelante, cuando usemos las interrupciones.
Código de arranque
Lo que haremos será definir nuestro propio código de arranque "crt0gb.s" que genere los bytes específicos de la cabecera de un cartucho GameBoy (de 0x0100 a 0x014F) y que, a continuación, defina las áreas donde deben estar alojado tanto el código y los datos de sólo lectura (de 0x0150 a 0x7FFF, 32 KBytes de ROM) como los datos de lectura y escritura (0xC000 a 0xDFFF, 8 KBytes de RAM).
.module crt0gb
.globl _main
.area _HEADER (ABS)
.org 0x0100
nop
jp init
; nintendo logo
.db 0xCE, 0xED, 0x66, 0x66, 0xCC, 0x0D, 0x00, 0x0B, 0x03, 0x73, 0x00, 0x83, 0x00, 0x0C, 0x00, 0x0D, 0x00, 0x08, 0x11, 0x1F, 0x88, 0x89, 0x00, 0x0E, 0xDC, 0xCC, 0x6E, 0xE6, 0xDD, 0xDD, 0xD9, 0x99, 0xBB, 0xBB, 0x67, 0x63, 0x6E, 0x0E, 0xEC, 0xCC, 0xDD, 0xDC, 0x99, 0x9F, 0xBB, 0xB9, 0x33, 0x3E
; game title (16 bytes)
.ascii "PRUEBA"
.db 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
; licensee code (0 for non super gameboy games)
.db 0x00, 0x00, 0x00
; cartridge type (0 for ROM only)
.db 0x00
; ROM size (0 for 32 KBytes)
.db 0x00
; RAM size (0 for none)
.db 0x00
; country code (1 for non japanese)
.db 0x01
; licensee code (0xA4 for Konami)
.db 0xA4
; version number
.db 0x00
; header checksum (run "header_checksum_calc < main.gb" to get this value)
.db 0x83 ; PUT HERE THE VALUE CALCULATED BY header_checksum_calc AND RECOMPILE
; checksum of all cartridge (GameBoy ignores this)
.db 0x00, 0x00
.org 0x0150
init:
call gsinit
call _main
exit_loop::
jp exit_loop
.area _HOME
.area _CODE
.area _GSINIT
gsinit::
.area _GSFINAL
ret
.area _DATA
.area _BSS
.area _HEAP
Los diferentes campos de la cabecera de un cartucho de GameBoy (entre las direcciones 0x0100 y 0x014F) se detallan aquí. Como se puede ver en la posición 0x0100 se ponen las instrucciones "nop" y "jp init", a continuación se definen todos los campos de la cabecera del cartucho y es a partir de la posición de memoria 0x0150 donde se pone el resto del código y se aloja la etiqueta "init". "call gsinit" es la parte encargada de inicializar variables globales y a continuación se hace un "call _main" para que se invoque la función "main" que se haya definido.
El trabajo con el SDCC es más artesanal que con el GCC ya que en SDCC no existe el concepto de "linker script" como en GCC y los processos de compilación y enlazado están algo más entremezclados. Ahora podemos definir un "main.c" que simplemente pinte la valdosa (tile) 0 en el extremo superior izquierdo de la pantalla:
#include <stdint.h> #define IE *((uint8_t *) 0xFFFF) #define LCDC *((uint8_t *) 0xFF40) #define STAT *((uint8_t *) 0xFF41) #define BGP *((uint8_t *) 0xFF47) #define BG_TILES ((uint8_t *) 0x8800) #define BG_DATA ((uint8_t *) 0x9800) const uint8_t TILE0[16] = { 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011 }; void memcpy(void *dest, void *src, uint16_t n) { uint8_t *d = (uint8_t *) dest; uint8_t *s = (uint8_t *) src; while (n > 0) { *d = *s; d++; s++; n--; } } void main() { // disable LCD before accessing VRAM while ((STAT & 0x03) != 0x01) ; LCDC = 0x00; // copy tile data to tile data VRAM memcpy(BG_TILES, TILE0, 16); // disable all LCD interrupts STAT = 0x00; // disable interrupts IE = 0x00; // configure palette BGP = 0b11100100; // 0b11 for full black, 0b10 for dark gray, 0b01 for light gray and 0b00 for white // enable LCD, disable window, background tile data at 0x8800, background tile indices at 0x9800, obj not displayed, background display on LCDC = 0x81; // with LCD enabled, wait vblank before accessing VRAM while ((STAT & 0x03) != 0x01) ; BG_DATA[0] = 0x80; // halt while (1) ; }
Para hacer este código sí hay que echar más mano de la documentación sobre el controlador gráfico de la GameBoy. En este caso se ha optado por un ejemplo muy simple:
1. Se deshabilita el controlador gráfico en el registro LCDC. Para hacerlo es necesario esperar a un retrazo vertical (vblank). Nintendo recomienda encarecidamente hacer esta espera previa antes de apagar el controlador LCD ya que, de lo contrario, puede dañarse la pantalla LCD (!).
2. Copiamos los 16 bytes de la baldosa (tile) 0 desde el array TILE0 a la VRAM que alberga los datos de las baldosas. La baldosa son simplemente 8 rayas verticales con las diferentes tonalidades de gris.
3. Se deshabilitan todas las interrupciones (registros STAT e IE a 0x00). Por ahora no estamos usando el mecanismo de interrupciones.
4. Configuramos la paleta (registro BGP).
5. Habilitamos de nuevo el controlador gráfico LCD.
6. Esperamos al siguiente retrazo vertical (vblank).
7. Escribimos en la VRAM encargada de indexar las valdosas que queremos mostrar, la baldosa 0 en la esquina superior izquierda.
8. Terminamos: bucle infinito.
Como se puede ver, se trata de un ejemplo muy simple y minimalista, pero que funciona. Ya tenemos preparado nuestro entorno de desarrollo para GameBoy.
Como emulador recomiendo el VisuaBoyAdvance-M, que es open source y muy estable. Aunque también nos podemos llevar la ROM a cualquier otro emulador o a un cartucho Flash que tengamos conectado a nuestra GameBoy física.

Interrupciones
De cara a hacer desarrollos más serios se hace necesario el uso y el aprovechamiento de las interrupciones en el Z80. El sistema de interrupciones en el Z80 depende de la implementación y, en el caso concreto del procesador SM83 y de la GameBoy, disponemos de las siguientes fuentes de interrupción (detalladas aquí):
- Int 0x40: VBlank.
- Int 0x48: Interrupción asociada al registro STAT del LCD.
- Int 0x50: Interrupción de timer.
- Int 0x58: Interrupción de puerto serie.
- Int 0x60: Interrupción de botones.
El valor "int 0xNN" hace referencia a la dirección de memoria del vector. En este caso, la dirección a la que salta el procesador cuando se produce una interrupción de retrazo vertical (VBlank) es la dirección 0x0040. Recordemos en la imagen del mapa de memoria, que las posiciones de memoria que están entre 0x0000 y 0x00FF se corresponden con los vectores de interrupción.
Modificaremos el ejemplo anterior para que el pintado de las baldosas se realice en la interrupción de retrazo vertical (VBlank). Para habilitar y trabajar con la interrupción VBlank hacemos lo siguiente:
1. Definimos en "crt0gb.s" el vector de interrupciones en la ubicación correcta (0x0040).
2. Habilitamos las interrupciones a nivel de sistema mediante la escritura en el registro IE (Interrupt Enable, dirección 0xFFFF).
3. Habilitamos las interrupciones a nivel de CPU mediante la instrucción ensamblador "ei".
En nuestro caso, para que la generación de código sea más cómoda, se define el nombre "vblankISR" como nombre que debe tener la función encargada de ejecutarse cada vez que haya un retrazo vertical. Dicho nombre se declara en "crt0gb.s" y se pone una instrucción "call _vblankISR" seguida de una instrucción "reti" en la posición de memoria 0x0040.
.module crt0gb
.globl _main
.globl _vblankISR
.area _HEADER (ABS)
.org 0x0040
call _vblankISR
reti
.org 0x0100
nop
jp init
...
Esto nos obliga a que tenemos que tener una función llamada "vblankISR" en C (con el atributo "__interrupt" para que guarde todos los registros al entrar y los restaure al salir) que se encargue de servir la interrupción de retrazo vertical (VBlank):
void vblankISR() __interrupt { // access VRAM here // ... }
La interrupción de retrazo vertical nos proporciona, además, una base de tiempo razonable, puesto que se ejecuta 60 veces por segundo. Aquí puede verse un ejemplo final de pintado mediante interrupción VBlank que cambia la ubicación de las valdosas una vez por segundo (cada 60 interrupciones):
#include <stdint.h> #define IE *((uint8_t *) 0xFFFF) #define LCDC *((uint8_t *) 0xFF40) #define STAT *((uint8_t *) 0xFF41) #define BGP *((uint8_t *) 0xFF47) #define BG_TILES ((uint8_t *) 0x8800) #define BG_DATA ((uint8_t *) 0x9800) const uint8_t TILE0[16] = { 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011 }; const uint8_t TILE1[16] = { 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000 }; const uint8_t TILE2[16] = { 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111 }; const uint8_t TILE3[16] = { 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111 }; void memcpy(void *dest, void *src, uint16_t n) { uint8_t *d = (uint8_t *) dest; uint8_t *s = (uint8_t *) src; while (n > 0) { *d = *s; d++; s++; n--; } } volatile uint8_t frameCounter; volatile uint8_t frameIndex; void vblankISR() __interrupt { if (frameCounter == 0) { // show tiles BG_DATA[0] = 0x80 + frameIndex; BG_DATA[1] = 0x80 + ((frameIndex + 1) & 0x03); BG_DATA[2] = 0x80 + ((frameIndex + 2) & 0x03); BG_DATA[3] = 0x80 + ((frameIndex + 3) & 0x03); frameIndex = (frameIndex + 1) & 0x03; frameCounter = 60; } else frameCounter--; } void main() { // disable LCD before accessing VRAM while ((STAT & 0x03) != 0x01) ; LCDC = 0x00; // copy tile data to tile data VRAM memcpy(BG_TILES, TILE0, 16); memcpy(BG_TILES + 16, TILE1, 16); memcpy(BG_TILES + 32, TILE2, 16); memcpy(BG_TILES + 48, TILE3, 16); // disable all LCD interrupts STAT = 0x00; // enable only vblank interrupt frameCounter = 0; frameIndex = 0; IE = 0x01; __asm__ ("ei"); // configure palette BGP = 0b11100100; // 0b11 for full black, 0b10 for dark gray, 0b01 for light gray and 0b00 for white // enable LCD, disable window, background tile data at 0x8800, background tile indices at 0x9800, obj not displayed, background display on LCDC = 0x81; // halt while (1) ; }

Checksum de la cabecera
El valor del byte de la posición de memoria 0x014D de la ROM del cartucho se calcula mediante un algoritmo de suma de control entre los bytes 0x0134 y 0x14C (ambos inclusive) de la misma ROM. El código para calcular este valor es el siguiente:
#include <iostream> #include <iomanip> using namespace std; int main() { cin.ignore(0x0134); uint8_t x = 0; for (int i = 0x0134; i <= 0x014C; i++) x = x - ((uint8_t) cin.get()) - 1; cout << "0x" << hex << setw(2) << setfill('0') << ((int) x) << endl; return 0; }
Una vez generada una ROM se realiza este cálculo y el valor generado debe ser colocado en la posición 0x014D de la propia ROM. Como se puede ver, los campos que afectan al valor de esta suma de control no dependen del código principal de la ROM con lo que no será un valor que se modifique con frecuencia.
Proceso de compilación
A modo de resumen, el proceso de compilación sería entonces el siguiente:
# ensamblamos crt0gb.s /opt/sdcc/bin/sdasgb -o crt0gb.rel crt0gb.s # compilamos main.c /opt/sdcc/bin/sdcc -msm83 -c -o main.rel main.c # enlazamos para generar la ROM "casi-final" indicando que la RAM está en 0xC000 /opt/sdcc/bin/sdcc -msm83 --data-loc 0xC000 --no-std-crt0 -o main.ihx crt0gb.rel main.rel /opt/src/Hex2bin-2.5/hex2bin -s 0 -e gb main.ihx # ahora tenemos la ROM "casi-final" en el fichero "main.gb" # compilamos en el host el generador de suma de control g++ -std=c++11 -o header_checksum_calc header_checksum_calc.cc # lo ejecutamos con el "main.gb" para que nos diga el valor de la suma de control ./header_checksum_calc < main.gb 0x83 # este valor 0x83 es el que debo poner en el fichero crt0gb.s como checksum (dirección 0x014D de la ROM) # si al poner este valor, se modifica el fichero crt0gb.s, hay que recompilar desde el principio # Nótese que este valor se calcula a partir de la cabecera, no a partir de toda la ROM, por lo que en circunstancias normales apenas cambia
Como se puede apreciar, uso la utilidad Hex2bin (http://hex2bin.sourceforge.net) para generar el fichero .gb (la ROM) a partir del fichero Inte Hex que genera el compilador SDCC.
Todo el código fuente puede descargarse de la sección soft.
[ añadir comentario ] ( 2648 visualizaciones ) | [ 0 trackbacks ] | enlace permanente |




( 3 / 3481 )
Calendario



