DEV Community

Cover image for MSX  Game with Z80 assembler
Eduardo López Ortega for Adevinta Spain

Posted on • Updated on

MSX Game with Z80 assembler

¿Por qué hacer un video juego para MSX?

MSX es una especificación de hardware y software destinada a producir ordenadores domésticos compatibles entre distintos fabricantes. La especificación surgió como una colaboración entre Microsoft y ASCII.

Z80 es un microprocesador de 8 bits que Zilog empezó a producir a mediados de los 70 siendo ampliamente utilizado en electrónica de consumo, en consolas de videojuegos y en los primeros ordenadores personales como los compatibles con la especificación MSX.

Típicamente corre a una frecuencia de 3.5Mhz. Tiene un bus de direcciones de 16 bits por lo que puede direccionar hasta 64 Kb de memoria y un bus datos de 8 bits (que determina fundamentalmente la potencia de proceso de datos).

Z80

  • No dispone de operaciones aritméticas habituales como multiplicación, división. Soporta suma/resta sobre 8/16 bits.
  • No tiene soporte para modo privilegiado/supervisor.

Observando las fechas citadas se puede intuir que la capacidad de cómputo de estos dispositivos era bastante limitada por lo que se hace difícil imaginar el motivo por el que alguien con toda la tecnología que tiene actualmente a su alcance querría embarcarse en un proyecto para la creación de un video juego en esta plataforma. El motivo es doble, nostalgia por un lado y una deuda personal por otro.

El juego

La intención es crear un "Shoot’em up" clásico con scroll vertical y una mecánica que se enriquezca a medida que se avanza en los niveles de juego.

El loop básico de renderización de cada frame (50 fps) en un juego de este tipo podría ser algo así:

  • Aplicar el ciclo de sonido
  • Aplicar las físicas a los objetos en pantalla
  • Determinar colisiones entre objetos
  • Calcular el nuevo estado de los objetos
  • Gestión de la escena de scroll
  • Gestión de la escena de los sprites
  • Transferir la escena a memoria de vídeo

¿Cómo, cuándo, cuánto?

El procesador de video (VDP) lee la memoria de video (VRAM) y representa su contenido a 50 fps. Cada 20 ms lee parte de los 16Kb (depende del modo de video seleccionado) de la VRAM.

La CPU en el loop de renderización de frame debe poder generar y enviar a la VRAM todo el estado necesario (16 Kb como máximo) para que el VDP lo envíe al monitor con la cadencia descrita.

Hay que tener en cuenta que la CPU y el VDP compiten por el acceso a la VRAM: la CPU para generar el resultado de la renderización del frame y el VDP para enviarla al monitor.

Por las características del sistema no siempre es posible implementar double buffering por lo que se complica aún más el proceso de transferencia del frame a VRAM, que se reduce al tiempo de vblank (que es el tiempo en el que el VDP no está accediendo a VRAM para representar la imagen) según puede observarse en la figura

Alt Text

En nuestro caso, muy grosso modo, las filas de pixels en vblank son aproximadamente ⅕ parte de la pantalla por lo que tendríamos aproximadamente 20 ms/5 = 4 ms para transferir (veremos que hay optimizaciones sobre esta idea) el contenido completo del frame durante el vblank que es el momento en el que el VDP no está accediendo a VRAM.

Teniendo en cuenta que el ciclo de reloj de la CPU es de 0.28 microsegundos (3.5 Mhz) y si tomamos como promedio 12 T-States/Instrucción disponemos de unas 1190 instrucciones a ejecutar en dicho período, que puede parecer suficientes pero dado el carácter fundamental y las cantidades de datos a transferir a VRAM no lo son (el total de la VRAM son 16384 bytes >> 1190).

Implementación

El juego de instrucciones del Z80 es bastante completo e implementa un conjunto suficiente de modos de direccionamiento.
La BIOS de MSX no ofrece ningún servicio de gestión de memoria ni tampoco proporciona formas sofisticadas para definir y manipular estructuras de datos complejas.

Estructuras de datos

Para modelar estructuras de datos complejas se suele utilizar el modelo de direccionamiento indexado utilizando los dos registros de índice (IX, IY) ambos de 16 bits.

Habitualmente se utilizan para apuntar al inicio de la estructura y acceder a los campos de forma indexada.

Supongamos que definimos una estructura de 5 campos

        STRUCT  RowStreamTileToVdp_FIELDS
BANK_FACTOR                     # 1 ; 1 byte
ROW_OFFSET                      # 1 ; 1 byte
CURRENT_ROW_ID                  # 2 ; 2 bytes
ROWSTREAM_ADDRESS               # 2 ; 2 bytes
STATUS                          # 1 ; 1 byte
        ENDS

Para cada campo, #n indica que se trata de un campo de n bytes (estamos utilizando para este caso sjasm compiler)

Para acceder a los campos tendríamos algo parecido a

LD      ix, RowStreamTileToVdp_FIELDS
;       BANK_FACTOR (1 byte)
LD      a, (ix + RowStreamTileToVdp_FIELDS.BANK_FACTOR)
;       ROW_OFFSET (1 byte)
LD      a, (ix + RowStreamTileToVdp_FIELDS.ROW_OFFSET)
;       CURRENT_ROW_ID (2 bytes)
LD      l, (ix + RowStreamTileToVdp_FIELDS.CURRENT_ROW_ID)
LD      h, (ix + RowStreamTileToVdp_FIELDS.CURRENT_ROW_ID + 1)
;       ROWSTREAM_ADDRESS (2 bytes)
LD      l, (ix + RowStreamTileToVdp_FIELDS.ROWSTREAM_ADDRESS)
LD      h, (ix + RowStreamTileToVdp_FIELDS.ROWSTREAM_ADDRESS + 1)
;       STATUS (1 byte)
LD      a, (ix + RowStreamTileToVdp_FIELDS.STATUS)

Memory manager

Supongamos que queremos sumar dos números de 8 bits almacenados en memoria (direcciones operando1 y operando2) que queremos depositar el resultado en memoria (dirección resultado).

            .ORG    $0 
            LD      a, (operando1)
            LD      hl, operando2
            ADD     a, (hl)
            LD      (resultado), a
            RET 
operando1:                
            DB      $2 
operando2:                
            DB      $3
resultado:                
            DB      $00 

Se espera que el resultado depositado en (resultado) sea $5 ($2 + $3)

Vemos que se trabaja directamente sobre direcciones de memoria y que podemos utilizar etiquetas (operando1, operando2 y resultado) para nombrarlas.
Este modelo es válido para datos estáticos, pero ¿cómo gestionamos los dinámicos? No tenemos ningún servicio del sistema (BIOS) para reservar y liberar memoria.

He decidido enfocar el desarrollo para maximizar la velocidad de ejecución, por lo tanto se optará por implementar un mecanismo muy sencillo, con muchas limitaciones (no se implementará la liberación de memoria por tanto tampoco la compactación) pero lo más eficiente posible.

El escenario es el siguiente: para el tipo de juego que estamos creando el caso de uso típico son objetos que entran en escena durante un período relativamente corto de tiempo y después desaparecen.

Marcianos

Un conjunto de enemigos aparecen en pantalla y al cabo de unos instantes o bien han sido destruidos o bien han salido de escena.

Vemos que el ciclo de vida de los objetos es un período de tiempo pequeño y esa característica es la que se a aprovechar para implementar el modelo de gestión de memoria básico. Los disparos de todo tipo son también un ejemplo claro.

El modelo implementado es similar a una cola circular con REAR fijo.

Alt Text

Se reserva una cierta cantidad de memoria (que será la gestionada de forma dinámica) para el manager y se utilizan tres punteros

  • BEGIN Inicio de memoria reservada
  • END Final de memoria reservada
  • FREE Inicio de la memoria actualmente disponible

El funcionamiento es muy sencillo: cuando se le requiere una cierta cantidad de memoria (ALLOC) se obtiene el puntero a la zona libre, comprueba que puede entregar el tamaño requerido, entrega la referencia e incrementa el puntero con el tamaño entregado (que apuntará a la nueva memoria disponible).

En caso de que se demande un tamaño superior al disponible el puntero se resetea al inicio de la memoria reservada (el contenido existente quedará sobrescrito con el uso). Este funcionamiento es muy simple ya que no contempla liberación de memoria (FREE) ni compactación por lo que es muy rápido en el servicio.

La precaución que se debe tener es la comentada para el caso de uso. El tiempo de ALLOC completo de la memoria asignada debe ser mayor que el máximo de los tiempos medios de los ciclos de vida de los objetos asociados.

Alt Text

  • El primer diagrama muestra el estado de un manager con toda su memoria disponible.
  • El segundo diagrama muestra el estado tras una primera operación (Alloc-1).
  • El tercer diagrama muestra el estado del manager tras una segunda operación (Alloc-2).
  • El cuarto diagrama muestra el estado del manager tras una tercera operación (Alloc-3) cuyo tamaño no estaba disponible y se entrega el bloque desde el inicio (BEGIN) sobrescribiendo el contenido existente.

Esta estructura es flexible ya que permite un uso para bloques de tamaño fijo conocido con lo que se puede implementar un ARRAY o bien de tamaño variable desconocido a priori.

También es posible combinar varios managers de forma jerárquica a partir de un único manager global para toda la memoria disponible:

  • Un manager general
  • Un manager específico para el personaje principal
  • Un manager específico para cada una de las fases
  • Un manager específico para tipos de enemigos con ciclos de vida similares
  • Un manager específico para elementos transversales como marcador, etc

Alt Text

OOP

No tenemos la posibilidad de utilizar OOP pero podemos utilizar el direccionamiento indexado para emular una pequeña parte de esa función. La idea es que el estado gestionado internamente por una instancia se manipula de forma indexada tal como se ha explicado anteriormente, por lo tanto, antes de invocar a una función el registro IX deberá apuntar al inicio de la memoria para dicha instancia (se puede ver un ejemplo completo aquí).

...
        ;       Instanciar objeto de tipo ExampleObject  
        LD      de, ExampleObject.SIZE
        CALL    MemoryManager.alloc
        LD      VAR_ExampleObject1, ix
        ;       IX apunta a los SIZE bytes allocatados de ExampleObject
        ;       ExampleObject utiliza IX para acceder a su estado
        CALL    ExampleObject.new
        ;       IX debe apuntar a la memoria de la instancia
        CALL    ExampleObject.func1
        ;
        ;
        ;       IX debe apuntar a la memoria de la instancia
...
        LD      ix, VAR_ExampleObject1
        CALL    ExampleObject.func2 
...

Algo no cuadra

Efectivamente, antes hemos visto que tenemos unos 4 ms para transferir la escena a VRAM (durante el vblank) para lo que disponemos de unas 1200 instrucciones y si tenemos que transferir unos 16Kb: los números no salen, no da tiempo.

La resolución del modo 2 del VDP es de 256 x 192 pixels con una paleta de 16 colores (4 bits = ½ byte).
Para completar una escena necesitaríamos 256 x 192 x ½ = 24576 bytes pero sólo tenemos 16 Kb de memoria.
Esto nos lleva a que este modo de vídeo tiene una particularidad necesaria para poderse acomodar a los 16384 disponibles de VRAM.

La organización de la VRAM para esto modo es la siguiente: los píxeles están agrupados horizontalmente de 8 en 8 (1 byte) y cada uno de estos tienen asociado otro byte para establecer el color de todos ellos (primer plano más el color de fondo), por tanto para establecer la forma (cuales están encendidos y cuales apagados) y el color de cada grupo de 8 pixels se necesitan 2 bytes por tanto se necesitan 12Kb para representar toda la escena.

Con esta forma de codificación de "colors" y "patterns" se ahorra memoria pero queda limitado el tipo de imágenes que se pueden generar ya que, de forma general, no podemos asignar un color a cada píxel de forma individual (el color es para cada 8 pixels horizontales + un color de fondo para todos ellos).

Aún así algo sigue sin encajar ya que en 4 ms no podemos transferir 12Kb desde memoria principal a VRAM por tanto hay que hacer algo extra.

Tiles

Imaginemos que tenemos un baño en casa y queremos alicatar las paredes con baldosas (tiles). Podemos tener 4 o 5 modelos de baldosas con las que podremos componer algún motivo (aunque poco sofisticado). Esa misma idea es la que podemos aplicar para construir nuestra escena.

Alt Text

Con un conjunto relativamente pequeño de tiles podemos obtener una escena por agregación. Con pocos tiles tendremos escenas muy sencillas, muy repetitivas pero que necesitan muy poca memoria. Por otro lado con muchos tiles será justo lo contrario. El objetivo para nuestro caso de uso es encontrar un buen equilibrio entre los dos extremos.

El VDP admite tiles de 8x8 pixels por tanto necesitamos 768 tiles para definir una escena por completo (256x192 pixels). Ahora sí que encaja perfectamente este tamaño (768 bytes) para poder transferirlo a VRAM durante el vblank.
El registro DE contiene la dirección de destino de los tiles en VRAM, el registro HL contiene la dirección de origen de los tiles en RAM y el registro BC el número de tiles a transferir.

    IN      a, (0x99)
      ;       set VRAM target address with DE register
    LD      a, e
    OUT     (0x99), a
    LD      a, d
    AND     0x3f
    OR      0x40
    OUT     (0x99), a
    DEC     bc
    INC     c
    LD      a, b
    LD      b, c
    INC     a
    LD      c, 0x98
      ;       loop for transfer complete block
.loop
    ; wait (at least) 29 t-states between VRAM accesses
    OUTI
    JR      NZ, .loop
    DEC     a
    JR      NZ, .loop

El tiempo de transferencia quedaría así

Para 768 tiles transferidos --> 21635T de CPU
Para 3.5 Mhz --> 6.181 ms de tiempo de transferencia

La CPU se comunica con la VRAM mediante los puertos 0x98 para leer/escribir el contenido y 0x99 para leer/escribir la dirección (esta descripción es intencionalmente incompleta pero es suficiente para comprender el enfoque).

Hay una cuestión a considerar respecto a la escritura en VRAM. Se deben insertar 29 ciclos de reloj de wait state para asegurar que no se pierde el dato y esto desfavorece aún más nuestro escenario, que necesita la máxima tasa de transferencia posible.

Vemos que el tiempo necesario para la transmisión es de 6.181 ms, es superior a los 4 ms (aproximadamente) de vblank pero es aceptable ya que estamos inferior de la escena y el VDP empieza a renderizar la desde la parte superior por tanto no hay concurrencia (a la misma parte de memoria) en el acceso a VRAM.

Scroll Vertical

Es la última parte de este documento (el proyecto real esta WIP justo en esta fase).

Por lo visto hasta el momento, podemos transferir un conjunto de tiles (768 en total = 32horz x 24vert) para componer la escena en cada frame a una frecuencia de 50 fps.

Alt Text

Según se muestra en la figura anterior el scroll se puede conseguir moviendo los tiles un offset de 32 (una fila entera) hacia arriba. Esto provoca un desplazamiento de 8 píxeles (la altura del tile).
En realidad esta es la técnica habitual en los sistemas que utilizan el TMS9918 ya que el tiempo de generación de la escena es corto y tiene un coste asumible (como ya hemos visto).

Para este juego en desarrollo se ha querido buscar una alternativa al scroll por bloques (8 pixeles) y buscar un scroll mas suave (2 pixeles en lugar de 8).

La idea general es:

  1. Para cada par vertical (bottom, top) de dos tiles distintos consecutivos generamos una secuencia de 4 tiles con la transición del tile superior al inferior.
  2. Cada ciclo de transferencia de tiles a VRAM enviaremos el [tile MOD 4] por tanto se enviará la sub-secuencia 0-1-2-3
  3. Al finalizar la sub-secuencia se aplica el algoritmo anterior de 8x8
  4. Este ciclo proporciona un efecto de scroll 2px

Aquí puede verse un ejemplo de implementación para generar la secuencia de 4 tiles a partir del top y bottom.

Aquí se puede ver un vídeo con un ejemplo de implementación

Everything Is AWESOME

Consideraciones finales

El hecho de utilizar un lenguaje de bajo nivel probablemente pone ya de manifiesto que el primer objetivo es maximizar el rendimiento (la plataforma utilizada presenta muchas restricciones y su potencia es baja respecto al caso de uso). Se pueden sacrificar algunos aspectos que en otros contextos no serían negociables como es el caso de la función tan limitada del memory manager por ejemplo.

Por otro lado, para este caso el ciclo de vida del producto se restringía al de desarrollo, una vez entregado no había “patches” ni evoluciones simplemente se creaba el soporte (cassette o cartucho rom) y se distribuía. Se puede hacer alguna concesión que elimine algunos aspectos mas abstractos que no estén alineados directamente con la resolución del problema (se podría favorecer en cierto modo la concreción respecto a la abstracción o re-utilización).

Por último, y dicho esto desde el punto de vista del desarrollo de software en la actualidad, utilizar herramientas y modelos de desarrollo de hace 30 años me ha revelado que es posible tener más paciencia en el trabajo y esfuerzo necesarios para obtener un resultado (bastante limitado además).

Lo veo como algo parecido a un trabajo de tipo artesanal que no puede acelerarse mucho por su propia naturaleza y que en la actualidad creo que no es frecuente encontrar (es posible que el desarrollo de software de sistemas embebidos pueda tener cierta analogía aunque lo desconozco) y esto en cierta manera es un gran contraste con el tipo de vida actual en el que todo es inmediato y efímero ...

Top comments (0)