cursos:ensamblador:avanzadas1

Consideraciones avanzadas sobre Código Máquina

A continuación vamos a ver consideraciones mucho más avanzadas, que sólo recomiendo tener en cuenta una vez el lector haya asumido correctamente todos los conceptos básicos del curso.



Una pregunta muy interesante podría ser: ¿cuál sería la dirección mínima que puedo usar en un ORG/CLEAR para disponer de la máxima memoria posible? ¿Cuánto espacio debo “dejarle a BASIC”?

En todos los programas que hemos visto, hemos recomendado ubicar nuestros programas en lugares como 35000, es decir, utilizar el área desde 35000 hasta 65535 (si no usamos los UDGs o Gráficos Definidos por el Usuario) o hasta 65338 si los usamos (porque los últimos 168 bytes se suelen reservar para los UDGs en BASIC).

Eso nos proporciona (con un ORG 35000) hasta unos 30KB de RAM en un Spectrum de 48KB para nuestro programa incluyendo código, textos, gráficos y sonidos. Podremos alojar datos y código, y leer y escribir en la memoria, en todo el espacio entre 35000 y 65535, es decir, en 30535 bytes (29.8KB, es decir, casi 30KB).

Si bajamos ORG hasta 30000, ganamos unos 5KB extras para nuestro programa. Bajándolo hasta 28000, ganamos 7KB. Y en 26000 tenemos aproximadante 9 valiosísimos Kilobytes adicionales.

Sin embargo, nuestra recomendación es utilizar ORG 33500 como valor mínimo para cargar nuestro programa.

Reducir la dirección de origen por debajo de 32768 tiene dos problemas que vamos a ver a continuación.


1.- BASIC y la pila del Z80 están por debajo de ORG

Por un lado, por debajo de nuestro programa están las variables del sistema, está BASIC, está el programa BASIC que estamos ejecutando y después del programa BASIC (decreciendo desde RAMTOP) tenemos la pila (stack) del Z80.

Es decir, que cuando usamos un ORG dirección, debajo de esa dirección debe caber tanto la pila como el programa cargador que vamos a usar para lanzar nuestro código máquina.

Hagamos un ejercicio de cálculo y supongamos que no queremos volver a BASIC al acabar de ejecutarse nuestro programa (algo que era normal en juegos y programas, donde simplemente reseteábamos el ordenador)… ¿hasta qué valor podríamos bajar el ORG y cargar los datos en él, sin afectar al cargador BASIC?

Como dirección mínima para un ORG, para que haya espacio para un cargador BASIC mínimo (un CLEAR X, un LOAD “” CODE y un RANDOMIZE USR dirección_de_inicio), el valor mínimo que recomendamos usar es $6000 (24576). Estamos hablando con este valor de dejar muy poco espacio para el programa en BASIC, es decir, dejar hueco para un cargador mínimo, simple y básico. Y además tiene que caber la pila del Z80. No es un valor recomendado a menos que tengamos necesidades extremas de memoria y que además vayamos a reubicar la pila a otro lugar.

En el Spectrum un programa Basic comienza normalmente a partir de 23755. Por poco código BASIC que introduzcamos en el cargador, prácticamente llegaremos hasta la dirección 24000. Por eso muchos cargadores Basic tenían una línea 0 en la que mediante un REM ya metían el cargador en código máquina (que además permitía cargar sin las cabeceras, ahorrando ese espacio).

Si el programa BASIC es más extenso que sólo un “CLEAR, LOAD ”“ CODE y RANDOMIZE USR” deberemos dejar más memoria para él y comenzar a cargar el código máquina (tanto el ORG, como el CLEAR, como la carga del código máquina) en una dirección de memoria superior y más segura, como 25600 ó 26000.

Si estamos realizando un programa íntegramente en ensamblador y no vamos a volver al BASIC, una vez se está ejecutando nuestro programa ensamblador, podemos utilizar para alojar variables de nuestro programa desde 23296 sin problema (es decir, “pisar” la memoria usada por BASIC) siempre que no llamemos a rutinas de ROM, ya que en esa zona están las variables de sistema que usan algunas rutinas (por ejemplo las propias de imprimir caracteres, mantener la posición donde imprimir, colores, etc). Recordemos además que deberemos reservar espacio para la pila que necesita el z80 para las llamadas a subrutinas y almacenamiento mediante PUSH y POP. Y por último, si “pisamos” las variables del Sistema es necesario desactivar las interrupciones nada más comenzar el programa.

Así pues, vemos que podríamos reducir la dirección de inicio de nuestro programa hasta 26000 y así ganar casi 9KB de uso para nuestro programa, y sin embargo hemos recomendado no bajar ORG de 33500. ¿Por qué?


2.- El stack del Z80 en Contended Memory

Si bajamos la dirección de inicio de nuestro programa por debajo de 32768, entramos en el bloque que va desde 16K-32KB (al menos la parte inicial del programa).

Como hemos visto anteriormente, ese bloque de RAM es “Contended Memory”, y su acceso sufre pequeñas ralentizaciones en lectura y escritura cuando la ULA está leyendo la pantalla. Eso hace que el código de nuestro programa que cae ahí sea ligeramente más lento (muy ínfímamente, pero lo es) que si lo ubicamos por encima de 32768.

El código que está en la contended memory tiene que ser leído por el procesador en el ciclo de “lectura-decodificación-ejecución”, y si la ULA está leyendo la pantalla cuando el procesador va a leer un opcode de nuestro programa en ese bloque de 16Kb, sufrirá un pequeño retraso en la lectura hasta que la ULA termine de leer el dato que está buscando en la videomemoria. Lo mismo ocurrirá al escribir y leer en celdillas de memoria de este bloque.

Entonces ¿por qué no ubicar nuestro programa en ORG 32768? ¿Por qué se ha recomendado un valor mínimo de ORG 33500?

Con ORG 32768 hay un problema, y es la pila (stack) del Z80. La pila crece habia abajo (decrece), y con ORG 32768, BASIC al hacer el CLEAR situará la pila Por deBAJO de 32768, con lo cual la pila estará en zona de contended memory.

Al igual que pasaba con el código de nuestro programa, en este caso cualquier operación PUSH, POP, call (que tiene que hacer PUSH de la dirección de retorno a la pila) y RET (que tiene que leerla para volver) podrá ser interrumpida y retrasada por la ULA. Y 'estas son operaciones que usaremos mucho en nuestros programas.

Para que la pila quede fuera de la contended memory, dado que BASIC la situa por debajo de ORG, hay que subir un poco ORG, para darle espacio dentro de estos primeros bytes del bloque 32K-48K. Si establecemos ORG en diferentes valores, y con el siguiente programa miramos el valor de SP al inicio de nuestro programa después de cargarlo, veremos lo siguiente:

; Programa utilizado para ver el valor de SP con diferentes ORG
    ORG 33500
 
    call ROM_CLS
 
    ld hl, 0
    add hl, sp
    ld b, h
    ld c, l
    call ROM_STACK_BC
    call ROM_PRINT_FP
 
    ret
 
ROM_CLS       EQU  $0daf
ROM_STACK_BC  EQU  $2d2b
ROM_PRINT_FP  EQU  $2de3
 
    END 33500

La siguiente tabla muestra cuántos bytes podemos apilar en la pila para que, decreciendo hacia abajo, llegue hasta 32767 y entre en la Contended Memory.


ORG x Posición de SP (pila)
tras RANDOMIZE USR x
Tamaño máximo de la pila
antes de entrar en Contended Memory
ORG 33500 33476 ~708 bytes
ORG 33800 33776 ~1008 bytes
ORG 34000 33974 ~1232 bytes (~1KB)
ORG 35000 34976 ~2232 bytes (~2KB)


ORG 35000 es el valor que hemos usado en muchos ejemplos en este curso: nos deja 30KB de memoria libre para código, y tenemos 2KB por debajo para la pila, lo cual es probablemente incluso demasiado.

ORG 34000 es una opción mucho más óptima seguramente, ya que una pila de 1KB es más que suficiente (probablemente siga siendo demasiado) y nos proporciona 1KB más de memoria para nuestro programa (~31KB).

Con ORG 33500 tenemos una pila de 708 bytes, que probablemente es más que suficiente para cualquier programa que podamos realizar, a menos que programemos algoritmos recursivos profundos, que hagan mucho uso de la pila. Ganamos otro medio KB para nuestro programa con este valor.

Cabe destacar que no estamos hablando de que sólo tengamos 708 bytes de pila, y que si hacemos PUSHes adicionales se vaya a colgar el programa. No, simplemente si hacemos muchas apilaciones de datos, sin sacarlos de la pila, los nuevos valores después de algo más de 350 PUSH'es caerían dentro de la contended memory, algo que tampoco es tan grave, y que probablemente ni siquiera tuvieron en cuenta muchos juegos comerciales de la época del Spectrum que usaban alegremente esta zona de memoria.

Y recordemos que la pila crece hacia abajo con PUSH y call pero decrece y tiende a volver a las direcciones más altas con cada POP y RET, por lo que lo normal es que se mueva en la parte superior donde la hemos situado, salvo que usemos funciones con recursividad, las cuales realizan muchos calls anidados y no realizan RETs hasta que sus llamadas a ellas mismas terminan (pero que, si están bien programadas, acaban vaciando la pila igualmente).

Así pues, nuestra recomendación de valor de inicio ORG mínimo para cualquier programa sería de cualquiera de esos 4 valores, ya sea 35000 si nos gusta ver un número redondo en el ORG, 34000 si queremos arañar un KB extra, o 33500 si estamos seguros de que tenemos pila suficiente con 708 bytes fuera de la contended memory.

Va a ser complicado que lleguemos a ocupar los 30-31KB de memoria que tenemos por delante, salvo que estemos realizando un juego de calidad comercial o con muchos datos. En ese caso, en este mismo capítulo veremos ideas para, entre otras cosas, darle un uso a esos 7KB de RAM “desperdiciados” que tenemos entre 25000 y 32768, pero que están en contended memory.


Supongamos que estamos haciendo un juego complejo, con muchos gráficos, sonidos y niveles, y nos encontramos con que esos ~32KB que van desde 33500 hasta 65535 no son suficientes. Nos faltan unos pocos Kilobytes para poder añadir una pantalla de final del juego, o más textos, más niveles o más gráficos.

En ese caso, tenemos varias opciones:


1.- Dividir el juego en varias cargas:

Tal y como hacían muchas aventuras conversacionales, o algunos juegos comerciales en las 2 caras de la cinta. Podemos dividir en juego en 2 cargas. Básicamente consiste en hacer “dos juegos” con el mismo motor del juego (o, si la segunda fase es radicalmente diferente, se trataría como un “juego diferente”), y pedir una clave de acceso para la segunda parte del juego, bien para asegurar que el jugador acabó la primera parte, o bien porque usamos dicha clave como dato para averiguar con qué puntuación, vidas u objetos acabó el jugador la fase anterior, para continuar con esa información en la segunda fase.

Podríamos “clonar” el código del juego en 2 carpetas con sus gráficos/sonidos/textos en cada una de ellas, pero implicaría mantener el código por duplicado, y cualquier bug se tendría que corregir en dos sitios.

Los más eficiente es utilizar un único código fuente del juego y funciones condicionales de nuestro ensamblador (de tipo IFDEF) para hacer INCBIN de los gráficos, mapas, niveles y textos de la fase 1 (o de las primeras N fases) o de la fase 2 (o de las últimas N fases), y ensamblamos uno y otro TAP por separado.


2.- Utilizar paginación de memoria:

Si no nos importa dejar de lado los modelos de 48K, y queremos aprovechar las capacidades de los modelos 128K (memoria extra y chip de sonido AY), podemos poner como requerimiento para nuestro programa el uso de un modelo de 128KB.

Esto nos proporcionará memoria adicional en el Spectrum, pero como ya hemos visto, con una limitación: de los 6 bloques de 16KB de RAM de “uso general” que tenemos en el 128KB, sólo podemos tener “instalado” o “visible” uno cada vez.


 Mapa de memoria

Para poner uno de los bloques disponibles en la “ventana” entre 49152 ($c000) y 65535 ($ffff), tendremos que perder de vista el bloque que tenemos “mapeado” actualmente.

Eso quiere decir que tenemos que organizar y diseñar nuestro código alrededor de esto ya que:

  • El código de nuestro programa deberá estar en la ventana que no se mapea (entre 32768 -$8000- y 49152 -$bfff-). Si vamos a cambiar de banco, la ejecución del programa no puede estar en el banco que vamos a “perder de vista”, de modo que el código ejecutable del programa tiene que estar en la memoria entre 25000 y 49152.
  • La pila tampoco puede estar situada en el bloque que paginamos, porque la perderíamos y el próximo POP/RET tendría resultados desastrosos. Por suerte, en nuestro caso estamos ubicándola por debajo del ORG así que no debemos de tener problemas.
  • Las rutinas de ISR y las rutinas que reproduzcan sonidos deberían también estar en la parte de la memoria no paginable, a menos que precisamente queramos aprovechar uno de los bancos de memoria del Spectrum para alojar la música y el reproductor. En ese caso en la rutina que atiende a la interrupción lo que haríamos sería cambiar de banco, llamar al reproductor para que “alimente” al chip AY, y volver a dejar el banco original antes de salir de la ISR.
  • Si no tenemos problemas de memoria y queremos hacer una versión 48K del mismo juego 128K, podemos hacer que la única diferencia entre ambos sea la carga de la música y el reproductor en un banco 128K. El “cargador” del juego (o el mismo juego, al principio) cargará primero la versión 48K. En ese momento deberá detectar si es un modelo 48K o 128K. Si es 128K, cambiará a otro banco, y cargará la música y el reproductor en él desde cinta (último bloque de la cinta) con la rutina de carga de la ROM. Usando la variable que hemos guardado sobre si estamos en un modelo 128K, en la ISR reproduciremos o no la música (cambiando al banco de la música y el player y deshaciendo el cambio). En este punto, la única diferencia entre las 2 versiones será que cuando juguemos en un 128K, la carga durará un poco más (se carga el bloque adicional con las músicas) y tendremos música AY. En un apartado posterior explicaremos de forma más detallada este proceso.
  • Durante la carga del juego podríamos ir cambiando de banco para cargar diferentes bloques de datos desde cinta en los diferentes bancos. Esto lo podemos hacer por ejemplo cargando primero en RAM una pequeña rutina de cambio de bancos llamable desde BASIC (con USR) para ir cambiando de banco y hacer los correspondientes LOAD “” CODE 49152 para cada banco a rellenar. También podríamos cargar un programa cargador propio que use la rutina de carga de la ROM para hacerlo.
  • Organizando bien los datos del juego, el mismo ejecutable podría ser versión 48K y 128K. Simplemente, en la versión 48K vamos cargando de cinta las fases en el último bloque de 16KB de RAM, mientras que la versión de 128K cargaría todas las fases en una única carga con cambios de banco.
  • Si nos ceñimos sólo a modelos 128K, nuestra rutina cargadora de datos desde cinta podría cargar datos comprimidos con ZX0 o RLE en una zona de memoria no paginable, establecer el banco deseado y descomprimir estos datos comprimidos en 49152. Para la carga de cada banco, podemos sobreescribir los datos comprimidos de la anterior carga, ya que los tenemos descomprimidos y seguros en el banco anterior. De esta forma, el tiempo de carga desde cinta se podría reducir a la mitad o un tercio para los datos del juego.


3.- Utilizar los ~7KB de memoria en el área entre 25000 y 32768:

Como ya hemos visto, en general si no tenemos problemas ni restricciones de memoria, lo ideal es evitar el área de memoria entre 25000 y 32768 al ser contended memory.

Siendo sinceros, estamos siendo algo extremistas evitando esta memoria, puesto que la penalización de velocidad es realmente ínfima. Por lo tanto, si nos sobra memoria libre lo normal es no usar este bloque de 7KB, pero si necesitamos algo de memoria adicional para alojar datos (o incluso código) no hay ningún problema en usarlo.

Seguramente, si ponemos la dirección de inicio de nuestro programa en 25000 ó 26000 y lo ejecutamos, no notaremos ninguna diferencia con tenerla en 32500 (ya hemos dicho que el impacto de la contended memory es ínfimo). Lo más normal tras este cambio sería no ver ninguna diferencia, a menos que utilicemos rutinas muy muy precisas, que utilicen temporizaciones o que hayan contado los t-stados de cada rutina para realizar hagan efectos en el borde o hacer cosas similares que requieran temporizaciones precisas.

En el caso de un juego, es normal que queramos que el código se ejecute lo más rápido posible, y que los datos (gráficos, mapas) que necesita para hacerlo, también sean de rápido acceso, pero ¿qué problema hay en tener en esos 7KB de memoria los datos que no se usan en el juego en sí?

Podríamos tener al principio de nuestro programa todo el código de inicialización del juego, el código del menú, los textos y gráficos del menú, los textos y gráficos de la pantalla “final” del juego, y cualquier otro dato que no sea parte del “juego” en sí. Nada mejor para alojar en este área que el menú del juego, donde no vamos a tener ninguna afectación por la memoria en contienda (y que, aunque la tuviéramos, no iba a penalizar el juego en sí).

De esta forma, todos los recursos relacionados con el menú (código, gráficos, textos) y con procesos separados del juego (como la rutina de detección de modelo 128K, por ejemplo), podrían estar al principio del programa ocupando estos 7KB, bajando el ORG desde 33500 hasta el valor deseado.

Para hacer esto, lo idea sería tener 2 ORG en nuestro programa en sjasmplus, o dos ficheros ASM en pasmo, para generar 2 ficheros BIN de código máquina separados. En el primero, tendríamos el punto de ejecución del inicio de nuestro programa.

Lo primero que hacemos es cambiar la pila para sacarla de la contended memory, asignándola a la misma dirección donde empieza el código del juego en sí (33500). Debido a este cambio, ya no podremos volver al BASIC si finalizamos el menú con un ret. Lo normal es que no se vuelva más al BASIC, pero si quisíeramos hacerlo, podemos guardarnos el valor de SP en una variable en memoria (pila_original DEFW 0) y restaurar el valor de SP antes de hacer un RET de salida del menú.

Lo siguiente que hacemos es llamar a todas las rutinas de inicialización de uso único que tenga el programa, alojadas en este “BIN” cargado en 26000 y que sólo llamaremos una vez o sólo llamaremos durante el menú.

Finalmente ejecutamos todo el código del menú hasta el momento en que el jugador comience la partida que deberemos saltar al punto de entrada del juego.

El BIN generado por el primer ASM se utiliza para generar el código que se cargará en 26000:

;;; Fichero menu.asm => genera menu.bin
    ORG 26000
 
    ; Ponemos la pila colgando del programa principal, y fuera
    ; de la contended memory. Si necesitaramos volver a BASIC
    ; habria que hacer una copia de SP en una variable para
    ; recuperarla antes del RET final del programa.
    ld sp, 33500
 
    call Detectar_Modelo_128K     ; rutina en este ASM / bloque
    call Inicializar_Algo         ; rutina en este ASM / bloque
 
Menu:
    ; codigo del menu aqui.
    ; ... en algun punto..
    call Iniciar_Partida
    jp Menu
 
graficos_menu DEFB .....
textos_menu DEFB .....
 
; rutinas que queremos alojar en este bloque de datos
 
    END 26000

El segundo BIN, el del juego, se define en 33500 y contiene todo el código del mismo, unos 32KB de código entero para nuestro programa, del que hemos “reducido” hasta 7KB al sacarlos al menú que está alojado a partir de 26000:

;;; Fichero juego.asm => genera juego.bin
    ORG 33500
 
Iniciar_Partida:
 
    ; codigo del juego aqui
 
    ; para volver al menu, un ret
    ret
 
    END 33500

Al tener 2 BINs, nos tendremos que hacer un cargador BASIC personalizado que cargue cada bloque de cinta en su dirección correcta, y crear una cinta que contenga:

CARGADOR_BASIC + bloque_bin_1 + bloque_bin_2

Otra opción, en lugar de colocar el menú a partir de 26000, sería comprimir los niveles del mapeado de nuestro juego en ZX0, e incluirlos con un INCBIN en un fichero ASM con origen 26000 para cargarlos en esa zona.

Después, en el código principal del juego antes de empezar cada pantalla, podemos “descomprimir” los datos de cada nivel en un buffer de trabajo en RAM y trabajar con esos datos durante todo ese nivel. El proceso de descompresión sólo se realiza una vez cuando empezamos una partida (nivel 1) o cambiamos de nivel.

La idea no es alojar gráficos o mapas en 26000 para usarlos directamente (puesto que al leerlos, nos podríamos ver afectados por la ULA cuando lee la pantalla) sino ubicar allí los datos comprimidos de forma que los desempaquetemos antes de usarlo en la RAM “convencional” de trabajo.

Los datos ideales para tener comprimidos y desempaquetarlos al vuelo son aquellos que cambian cada cierto tiempo (por ejemplo al cambiar de nivel) como músicas, mapeados (tilesets) o gráficos específicos de cada mundo, que desempaquetaríamos en el área de trabajo en la rutina de inicialización de cambio de nivel del juego.

Por contra, no suele valer la pena comprimir los gráficos más comunes y que están presentes en todos los niveles del juego, como los sprites del protagonista, ya entonces siempre tendríamos 2 copias: la versión comprimida y la descomprimida.


Hay 2 mecanismo de “ahorro de memoria extremo” que nos permitirán mejorar tanto el uso de memoria como los tiempos de carga desde cinta.

El primero, el espejado, es evidente cuando nos fijamos en los gráficos de los personajes de los juegos. Normalmente el gráfico del personaje mirando a la izquierda es igual que el del personaje mirando a la derecha, salvo que “reflejado” o “espejado”.


 Espejado

Así, podemos guardar en nuestro TAP resultante (y por tanto en el código ensamblador que vamos a compilar) sólo una de las versiones de las animaciones (por ejemplo, las animaciones del sprite sólo mirando a la izquierda).

Teniendo sólo la animaciones en una dirección, tenemos 2 opciones:

1.- Si al iniciar el juego mediante código espejamos todos los gráficos en memoria, en un proceso único inicial, habremos ahorrado tiempo de carga desde cinta, pero no memoria (nuestro sprite, una vez espejado tras su carga, ocupará lo mismo en memoria que si lo hubiéramos tenido en cinta mirando en las dos direcciones).

2.- Podemos dejar el sprite en memoria sólo mirando en una dirección, e imprimirlo espejado en tiempo real durante la ejecución del juego, mediante una rutina que lea los bytes “al revés” (en el eje que queramos espejar) para representarlos en pantalla.

Esta segunda técnica tiene una ventaja y un inconveniente.

La ventaja es que si añadimos la funcionalidad de “espejar sprites” a nuestro motor de dibujado de Sprites, ganamos en posibilidades porque podremos espejar también los tiles gráficos de los mapeados y ganar en variedad visual en el juego.

La desventaja es que la rutina que dibuja el sprite espejado tardará un tiempo diferente en dibujar el sprite que cuando lo dibuja sin espejar. Para solucionar eso, lo que podemos hacer (y hacen algunos juegos homebrew modernos) es preparar los gráficos de forma que la mitad del Sprite mire en una dirección y otra mitad en la otra dirección. Así, sólo tendremos que espejar medio Sprite y el tiempo de dibujado será siempre el mismo mire hacia donde mire el Sprite.

El segundo mecanismo de ahorro de tiempo de carga si usamos gráficos con máscaras, es “autogenerar” las máscaras a partir de los gráficos del sprite, algo que se puede realizar vía código si son convexos.


Existe una zona de memoria en el Spectrum de 48K que BASIC utiliza como “Buffer de Impresión”. Es un buffer temporal usado por la impresora del Spectrum cuando está imprimiendo, pero que no se utiliza en caso contrario.

Este área son 256 bytes, desde 23296 (5B00h) a 23551 (5BFFh).

Hay gente que pone la pila en la posición de memoria del buffer de impresión, que puede ser suficiente si no vamos a hacer más de 128 anidaciones (cada PUSH son 2 bytes), pero que puede causar problemas en los modelos de 128K, por lo que no es recomendable cambiar la pila a esta ubicación.

Aún así, podemos utilizar esa zona como “pila” o incluso como “zona de variables” de nuestro programa y así ahorrar hasta 256 bytes de RAM, teniendo en cuenta 2 cosas:

1.- Está en la Contended Memory, aunque no es un problema grande para lo que es el acceso puntual a variables.

2.- Los modelos de 128 tienen ahí rutinas de paginación de las ROM de sintaxis 128 y también partes del intérpretes de 48. Si pretendemos volver al BASIC después de ejecutar nuestro programa, o usar rutinas de la ROM en un 128K, no podemos tocar ese área.

3.- Tampoco podremos usarlo en modelos 128K si tenemos activas las interrupciones en im1 (Modo de Interrupciones 1). Si usamos nuestra propia rutina ISR en im2 y desde ella no llamamos a la im1 (para que actualice FRAMES u otras variables del sistema), no debería haber problemas en utilizar ese área si vemos que nuestro programa crece y necesitamos arañar unos bytes para las variables del mismo.




Si queremos ahorrarle tiempo de carga a un usuario de cassette en un Spectrum real, no hay nada como aprovechar la compresión de datos (usando RLE o ZX0 por ejemplo).

Una pantalla de carga de casi 7KB, comprimida, se puede quedar en menos de 3KB (a veces incluso 1.5 - 2 KB). Es una reducción de como mínimo el 50% del tiempo de carga, si no de un 66% en algunas ocasiones.

Para eso, guardamos la pantalla comprimida, y en lugar de cargar la pantalla con LOAD “” SCREEN$, lo hacemos con un cargador propio que carga los datos comprimidos en cualquier zona libre de la memoria, y después los descomprime directamente en la videomemoria (16384).

Una vez descomprimida la pantalla, podemos sobreescribir todo el buffer de datos comprimidos con nuestro código del programa.

Siempre que sea posible, guardar en cinta la pantalla de carga comprimida hará que el tamaño del TAP resultante sea menor y también el tiempo de carga para el usuario en un equipo real.


No es posible ahorrar tiempo de carga comprimiendo la pantalla de carga. También podemos comprimir los datos gráficos, sonoros, o los mapeados del juego.

Para eso, podemos por ejemplo hacer un pequeño programa cargador en 26000, el cual cargue bloques de datos de hasta 5KB desde cinta (datos comprimidos con ZX0) en 27000, y que luego los descomprima en sus ubicaciones reales en memoria, en las direcciones donde después los utilizará el programa principal que cargaremos al final.

Podríamos incluso cargar parte del código de nuestro programa comprimido, y desempaquetarlo en RAM.

Para poder hacer esto necesitaríamos utilizar la rutina de carga de cinta de la ROM (o una rutina hecha por nosotros), la rutina descompresora, y haber generado los “BINs” que cargamos desde cinta con “ORGs” que serán donde cargaremos cada bloque.

Por ejemplo, si tenemos:

;;; Bloque codigo menu.asm
    ORG 33500
 
   ; código del menu aqui
 
   ; datos del menu aqui
   INCBIN "graficos_menu.bin"

El BIN generado al ensamblar este programa lo podremos cargar con nuestro cargador (asumiendo que no ocupe más de 5KB) entre 27000 y 32000, y desempaquetarlo en 40000, para que todo el código y datos del menú desempaquetados acaben en la posición de memoria esperada (donde estarían con un LOAD “” CODE si los hubiéramos cargado sin comprimir).

Así, nuestro programa “inicial”, el “autoejecutable” que lanzaríamos desde BASIC, sería un programa en código máquina que contendría el descompresor de ZX0, y tal vez rutinas de detección de modelos 128K, y haría lo siguiente:


  • Cambiaría la pila a la dirección 33500.
  • Cargaría mediante rutinas de la ROM la pantalla de carga comprimida en 27000, y después la descomprimiría directamente usando la rutina descompresora sobre la videoram (16384), haciendo aparecer la pantalla de carga, con un tiempo de carga 1/2 o 1/3 del tiempo normal.
  • Cargaría mediante rutinas de la ROM el código y datos del menú, y lo desempaquetaría en 33500.
  • Si queremos aprovechar funcionalidades 128K, cargaría mediante rutinas de la ROM datos de sonido en 27000, mapearía un banco de memoria libre para almacenar la música, y desempaquetaría los datos comprimidos de 27000 en el banco mapeado. Después recuperaría el banco original.
  • Finalmente, podría cargar uno o más bloques de gráficos y código desde cinta (en bloques de 5KB) y desempaquetarlos en sus ubicaciones correctas.


Es un proceso laborioso, pero reduciría en mucho el tiempo de carga de cualquier juego, a costa de tener que hacer un pequeño puzzle de cargas de datos comprimidos y direcciones de inicio, y de montar un TAP con todos los componentes en el orden correcto.



Como acabamos de ver, si nuestro programa principal está realizado en BASIC y lo que vamos a hacer es llamar a rutinas en código máquina desde él, estas rutinas deberían ser cargadas mucho más allá de la dirección 26000, ya que tiene que caber no sólo un simple cargador BASIC, sino todo nuestro programa. En este caso, lo normal sería cargar las rutinas en la parte alta de la memoria (por ejemplo, según cómo de extenso sea el programa en BASIC, en 50000, con su CLEAR 49999 previo), ya que la mayor ocupación de memoria en este caso será seguro por parte del programa BASIC y no de las rutinas en código máquina (para la cual hemos dejado unos 15KB empezando en 50000 y acabando donde empiezan los UDG, en 65368).


La siguiente consideración que debemos de tener es cómo llamar a las rutinas BASIC.

Recordemos que tenemos 3 opciones:


PRINT USR dirección

Esta opción no suele ser utilizada ya que imprimiría por pantalla el valor del registro BC tras volver de la rutina. Esto la hace útil para pruebas pero no para llamar repetidamente a rutinas desde el programa principal BASIC.


RANDOMIZE USR dirección

En esta opción, se ejecuta el USR dirección, lo que produce un salto a la rutina en dicha dirección. Cuando esta acaba, el valor del registro BC se devuelve a BASIC y se asigna como parámetro a RANDOMIZE.

Esto produce que el valor devuelto por USR se use como semilla para números aleatorios. Es una forma de ejecutar código máquina sin sacar nada por pantalla y con una disrupción mínima para BASIC (sólo cambiamos la semilla de los números aleatorios). Es la forma normal de llamar a código máquina desde cargadores BASIC cuando no pretendemos continuar la ejecución en BASIC ni volver a él.

Pero si estamos haciendo un programa en BASIC con llamadas a rutinas código máquina, y la parte BASIC usa llamadas a RND para obtener números aleatorios, no querremos que se estropee el valor de la semilla de números aleatorios (porque será establecida con el valor que dejemos en BC) en cada llamada. En ese caso no debemos utilizar RANDOMIZE USR, o, si lo hacemos, nos puede interesar finalizar nuestras rutinas en código máquina con:

    ld bc, (23670)
    ret

El valor de 23760 ($5c76) es el actual valor de SEED, la variable del sistema que contiene la semilla por defecto que tuvieramos antes de la llamada, con lo si lo asignamos a BC antes de retornar, el comando RANDOMIZE volvería a establecer dicho valor y así lo preservaríamos al volver a BASIC.


LET V=USR dirección

Finalmente tenemos LET USR. Si no queremos que aparezca nada por pantalla (PRINT USR) ni tampoco alterar el valor de la semilla de números aleatorios, ni tampoco preservarla al final de cada rutina (RANDOMIZE USR), podemos optar por usar LET con una variable desechable para poder llamar a USR y desechar el valor devuelvo por la rutina en BC. Este valor se asignaría a la variable que hayamos escogido en BASIC (y que se recomienda que sea de una sóla letra ya que es más rápido en el BASIC del Spectrum) y simplemente podemos ignorar su valor.


Otra restricción importante sobre nuestro código máquina si lo vamos a llamar desde BASIC es que no debemos de utilizar los registros del Z80 llamados IX e IY en funciones que vayamos a llamar desde BASIC.

De hecho, no deberíamos utilizar IY ni siquiera desde ensamblador puro si estamos usando el modo de interrupción im1.

Como ya comentamos, la rutina de la ROM $0038, llamada en im1, utiliza el registro IY para actualizar el tercer byte de la variable FRAMES con un INC (IY+$40) por lo que si modificamos IY en nuestros programas y se produce una interrupción, modificaremos una zona de la memoria que no es la esperada. Si necesitamos usar IY, es mejor desactivar las interrupciones antes de hacerlo, y volver a activarlas después.


Tampoco se debe cargar el registro “I” con valores entre 40h o 7Fh (aunque no se vaya a usar im2). Sólo se deben redirigir las interrupciones im2 entre 8000h y BFFFh. Más adelante hablaremos de lo que son los modos de interrupción.


Si tenemos un juego exclusivamente 48K y queremos hacer una versión 128K simplemente añadiendo música AY y que el mismo juego sirva para ambos sistemas, con esa única diferencia, podemos hacer lo siguiente:


1.- En el cargador del juego, una vez cargado todo el código del mismo (lo que sería la versión completa 48K), con las optimizaciones de carga que se deseen (compresión, etc) se ejecuta el RANDOMIZE USR que lanza la versión 48K del mismo.

2.- En el inicio de nuestro juego, utilizamos la rutina de detección del modelo 128K mediante comprobación de la paginación, y averiguamos si es un modelo 128K ó 48K.

3.- Si el modelo es 48K, simplemente lanzamos el juego.

4.- Si el modelo es 128K, entonces conmutamos de banco los 16K finales a uno de los bancos libres y cargamos en él mediante la rutina de carga de la ROM un bloque de datos extra que habrá al final de la cinta (a continuación del juego 48K) conteniendo el player de música AY y la música 128K. Ese bloque, de hasta 16KB, puede tener varias melodías y también gráficos extra por ejemplo para que la versión 128K tenga una “introducción” no presente en la versión 48K.

5.- Cuando termina de cargar este bloque extra, volvemos a poner el banco original para dejar lo que son los datos del juego 48K, y continuamos la ejecución del mismo.


Ya una vez dentro del juego en sí, hacemos lo siguiente:

1.- Antes de mostrar el menú del juego 48K, si estamos en modo 128K (consultando la variable que estableció la rutina detectora de modelo) podemos utilizar los gráficos extra cargados en otro banco para mostrar la introducción (que sólo se verá al arrancar el juego). Conmutamos al banco y pintamos las pantallas, textos, animaciones, etc. Al acabar la intro, volvemos al banco original.

2.- En la rutina ISR im2 del juego, si es un modo 128K, conmutamos al banco de la música y llamamos al player. A este player le pediremos lo que sea necesario (parar, arrancar música, cambiar de melodía, etc) según lo que indique alguna variable de control que tengamos en memoria para que el im2 sepa qué tiene que hacer con la música en cada momento. Esta variable la controlaremos desde el juego en sí como veremos en el paso 5.

3.- Una vez se ha llamado a la rutina del player y hemos vuelto de ella (todavía en la ISR), si estamos en modo 128K ya se puede volver a conmutar al banco de memoria original, para que la ISR siga ejecutándose como en los modelos de 48K.

4.- Al repetirse este proceso 50 veces por segundo, sonará la música AY constantemente actualizada.

5.- En determinados momentos del juego (menú, cambio de pantalla, muerte del jugador, etc) si estamos en el modelo 128K se deberá establecer en alguna variable de “control del player” la instrucción para que en la próxima ISR el player haga el cambio oportuno, con órdenes del tipo “detén la música” o “reproduce la pista de la dirección X”.

Con esto el mismo juego 48K podrá tener música en modo 128K con unos pequeños añadidos muy sencillos, y ser compatible con ambos modelos.



Ya vimos en un capítulo anterior un ejemplo de código automodificable. Consiste en sobreescribir en memoria un opcode o un dato en función de una condición, para que cuando lleguemos a ese punto del programa, se ejecute el opcode modificado.

En el ejemplo que ya vimos, utilizamos código automodificable para sobreescribir un instrucción de carga “futura” que nos permitirá recuperar el valor del registro BC más adelante, por ejemplo porque vamos a modificarlo con un bucle, y no tenemos la pila disponible:

    ld (save_bc+1), bc    ; Escribimos BC en la parte NN NN del
                          ; opcode "ld bc, NN NN" en memoria
 
    ... hacer cosas con BC, perdiendo su valor ...
 
save_bc:
    ld bc, $0000          ; En el LD anterior cambiamos $000
                          ; por el valor de BC, así que cuando
                          ; el z80 llegue aquí no es ya ld bc, 0
                          ; sino ld bc, valor_que_tenia_BC
                          ; así que recuperaremos BC aquí.

Cuando se ejecuta la instrucción ld (save_bc+1), bc, estamos sobreescribiendo nuestro propio programa, cambiando el opcode que estaba ensamblado en memoria (ld bc, $0000, es decir “$01 $00 $00”) por $01 XX XX, siendo XX XX el nuevo valor que queremos introducir en el opcode.

Cuando se ejecuta el ld (save_bc+1), bc, estamos escribiendo el valor de BC en ese momento encima de “XX XX” de forma que cuando la ejecución llegue a ese punto, el comando ld bc, XX se ejecutará y recuperará en BC el valor que tenía BC cuando se almacenó en esa posición de memoria.

La escritura en memoria (ld (nn), bc = 20 ciclos de reloj) y su posterior asignación (10 ciclos) cuesta un total de 30 ciclos de reloj, mientras que si hubiéramos usado PUSH/POP costaría sólo 21 ciclos en total (11 el PUSH y 10 el POP). Sin embargo, si estamos en una rutina de impresión de Sprites donde usamos la pila para recuperar datos del Sprite, por ejemplo, es una forma de recuperar valores de registros en una situación donde no podemos hacer PUSH y POP para ello.

Y no sólo podemos modificar datos, también podríamos modificar opcodes, cambiando JR Zs por JR NZs, por ejemplo, para cambiar la condicionalidad de los saltos de alguna forma.


Utilizando CALL y la pila del Z80, podemos obtener en un registro el valor del Contador de Programa o PC:

    call NextInstr       ; CALL mete PC en la pila
NextInstr:
    pop hl               ; HL contiene ahora PC

Al ejecutarse call NextInstr, el Z80 mete en la pila la dirección de retorno para el CALL, en este caso, justo la dirección en la que está el contador de programa. Una vez hecho eso, no hacemos un RET para volver sino que hacemos POP HL para extraer PC de la pila sin volver al punto de llamada. El flujo de ejecución continuaría tras el pop hl, con el valor de PC introducido en HL.

¿Para qué podría valer algo así? Nuestro compañero Xor_A [Fer] nos proporciona un ejemplo con el cual podemos utilizar el valor de PC para imprimir cadenas sin tener que cargar en HL cada vez la posición de la cadena, intercalando el código y los datos:

Imprimir_Mensajes:
    call MyPrintString
    DB "cadena1", $ff
    call MyPrintString
    DB "cadena2", $ff
    call MyPrintString
    DB "cadena3", $ff
    (...)
 
MyPrintString:
    pop hl          ; obtenemos la direccion de la cadena (PC)
mpr_loop:
    ld a, (hl)
    cp $ff
    Jr z, mprexit
    rst $10
    inc hl
    jr mpr_loop
mpr_exit:
    push hl         ; introducimos direccion de retorno
    ret             ; PC continuará después de la cadena impresa

La llamada a MyPrintString mete en la pila la dirección de PC, que es la dirección del primer carácter de la cadena.

La rutina extrae ese valor de la pila y lo usa como “puntero a la cadena” en HL. Incrementa HL tras imprimir cada carácter, y al acabar de imprimir la cadena, mete en la pila el valor de HL para que el RET vuelva al flujo principal del programa, pero justo después de los datos de la cadena, es decir, al siguiente call MyPrintString.

Esto nos evitaría lo siguiente, que suponen 3 bytes por cada instrucción ld hl, NNNN:

    ld hl, cadena1
    call MyPrintString
    ld hl, cadena2
    call MyPrintString
    ld hl, cadena3
    call MyPrintString


[ | | ]

  • cursos/ensamblador/avanzadas1.txt
  • Última modificación: 21-01-2024 10:31
  • por sromero