Archivo por días: 2 enero, 2021

Pintando en el Spectrum (3)

El código anterior es funcional, pero tiene el problema de que ocupa 541 bytes (código más la tabla de direcciones). Teniendo en cuenta que la memoria de vídeo son casi siete kbytes, y otros siete más para el segundo buffer, tenemos que esos 541 bytes pueden ser un gasto excesivo de los 34,5 kbytes restantes de un Spectrum 48K (en un 128K tenemos doble página por hardware, con lo que no necesitamos nada de esto).

Para solucionarlo, en lugar de utilizar una tabla con todas las direcciones de memoria podemos utilizar una tabla sólo con las direcciones de inicio de cada bloque de caracteres (o sea, una de cada ocho filas de píxels). Esto es posible porque pasar de una fila a la siguiente en un bloque de caracteres es relativamente sencillo: sólo hay que incrementar el byte alto. Así, si tenemos en DE la dirección de un byte en una fila, sólo necesitamos hacer INC D para pasar a la siguiente fila (siempre que no sea la última fila de un carácter, claro).

Así que combinando todo lo anterior, podemos usar esta función:

    LD SP, tabla_direcciones
    LD HL, buffer
    LD BC, 0
    EXX
    LD HL, buffer + 6144
    LD DE, 0x5800 ; zona de atributos de color
    LD BC, 768 ; 32 columnas * 24 lineas
loop_l1:
    EXX
    INC B ; como BC es cero, esto es igual que LD BC, 256
          ; esto son 32 x 8 bytes en una fila de caracteres
    POP DE ; obtenemos la dirección inicial de la fila de caracteres
    LD IXh, E ; usamos una instrucción no oficial porque
              ; estamos justos de registros
    LD A, D
loop_l2:
    INC A ; preparamos ya la dirección de la siguiente fila
          ; hay que hacerlo antes de LDI porque INC modifica
          ; los flags
    LDI
    ... ; 32 LDIs en total
    LDI
    LD E, IXh ; recuperamos la posición inicial
    LD D, A ; y la siguiente fila
    JP PE, loop_l2
    EXX
    LDI
    ... ; 32 LDIs en total
    LDI
    JP PE, loop_l1

...
tabla_direcciones:
    DEFW 0x4000, 0x4020, 0x4040, 0x4060, 0x4080, 0x40A0, 0x40C0, 0x40E0
    DEFW 0x4800, 0x4820, 0x4840, 0x4860, 0x4880, 0x48A0, 0x48C0, 0x48E0
    DEFW 0x5000, 0x5020, 0x5040, 0x5060, 0x5080, 0x50A0, 0x50C0, 0x50E0

Y con esto lo tenemos ya, y altamente optimizada, pues en realidad esto ha sido el final de muchas iteraciones. Empecé utilizando DJNZ para las ocho iteraciones del bucle interno (loop_l2), y para conservar la dirección inicial de la fila de caracteres, lo que hacía era meter DE de nuevo en la pila con un PUSH, para sacarlo después de los LDIs y poder incrementar D para pasar a la siguiente fila. Esto era mucho más rápido que almacenarlo en alguna zona de la memoria (20 Tstados) o que decrementar dos veces el puntero de pila para que volviese a apuntar al valor inicial (12 Tstados).

La siguiente optimización que hice fue almacenar el valor de E en el registro A, de manera que después de los LDIs sólo tenía que hacer un LD E, A y ya tendría el valor original. Por desgracia esto CASI funcionaba, pues en las filas de caracteres 7, 15 y 23, al llegar al final de la línea se producía un desbordamiento y se sumaba uno a D, con lo que fallaba. Una solución sería reducir el número de columnas de 32 a 31, pero no era una solución muy elegante, así que al final decidí que era mejor almacenar D en A, y guardar E en una posición de memoria. Pero como no existe LD (nn), E ni la inversa, no podía hacerlo directamente… a menos que usase código automodificable. ¿Qué es esto? La instrucción LD E, 0 se codifica como 0x1E 0x00, siendo el segundo byte el valor a meter en el registro E. Así que lo que hacía era poner un LD E, 0 justo después de todos los LDIs, y después del primer POP DE almacenaba el valor de E justo en el segundo byte de la instrucción LD E, 0. De esta manera, cada vez que se hace una pasada en el bucle, el código ha cambiado. Aunque es más rápido, pues mientras que el par PUSH-POP son 22 Tstados en cada fila, con esto eran sólo 11 Tstados dentro de loop_l2, no me gusta nada usar código automodificable, así que me devané los sesos hasta que me acordé de que tenía cuatro registros extra de 8 bits: las mitades de IX e IY. Es cierto que son funcionalidades no documentadas, pero funcionan en todos los Z80. Así que la solución fue almacenar E en IXh (que consume 8 Tstados), y guardar D en A. Y además, el resultado es ligeramente más rápido también: aunque dentro del bucle pierdo ocho Tstados, pues LD E, 0 es 1 Tstado menos que LD E, IXh y el bucle se repite ocho veces, los compenso fuera, pues LD A, E son 4 Tstados y LD (nn), A son 13, 17 Tstados en total, mientras que LD IXh, E son sólo 8, con lo que, al final, usando IXh en lugar de código automodificable ahorro 1 Tstado por cada fila de caracteres (8 filas de píxels). Además, no hay que olvidar tampoco que esos 7 Tstados se convertirían en más siempre que hubiese contienda en la memoria, con lo que es un win-win.

Y con esto tenemos una función que ocupa 195 bytes en total (171 bytes más 24 de la tabla), a costa de ser un poco más lenta. ¿Cuanto más? El bucle interno son 4 + 512 + 8 + 4 + 10 = 538 Tstados, y hay que repetirlo 8 veces. Pero en caso de contienda, serán 544 Tstados, luego la duración será entre 4 304 y 4 352 Tstados. El bucle externo son 4 + 4 + 11 + 8 + 4 + 4 + 512 + 10 = 557 Tstados, pero con contienda serán 560 Tstados, y esto repetido 24 veces, una por cada fila de caracteres. Con esto tenemos que copiar una pantalla completa serán entre 116 664 y 117 888 Tstados. Aplicando la fórmula de la entrada anterior podemos aproximar a 117 597 Tstados, lo que es superior a los 112 800 Tstados de una pantalla completa. Si sólo hacemos 23 filas necesitamos 112 696 Tstados para copiar la pantalla frente a 111 008 Tstados disponibles antes de que nos alcance el haz. Pero si hacemos 22 filas, tardaremos 107 797 Tstados, frente a 109 216 Tstados que tarda el haz en alcanzar ese punto, por lo que con esta rutina, aunque ahorramos casi dos tercios de memoria, perdemos una fila respecto a la rutina anterior.

Sin embargo, no debemos olvidar que esto sólo significa que no podemos tener animaciones fluidas en las dos últimas filas de la pantalla, pero sí podemos tener gráficos estáticos o semi-estáticos como un marcador, un inventario… cosas que no cambien demasiado a menudo de manera que un artifact durante su modificación pase desapercibido.

Pintando en el Spectrum (2)

Esta mañana estaba revisando mi código y se me ocurrió una pequeña optimización. JP cc, nn (salto condicional) necesita 10 Tstados, mientras que DJNZ necesita 13 cuando no se cumple la condición, y 8 cuando sí se cumple. Dado que siete veces no se cumple pero una sí se cumple, si podemos sustituir el DJNZ por un JP pe, nn, ahorraremos 19 Tstados en cada fila. Sólo tenemos que cargar BC con 256, que es el número de transferencias que tenemos que hacer entre grupos de atributos. Pero además, dado que cada vez que terminamos una fila de caracteres BC valdrá cero, podemos simplemente incrementar B en uno, que es una operación más rápida que cargar un número, con lo que ahorraremos 3 Tstados más. Así quedaría el código:

    LD SP, tabla_direcciones
    LD HL, buffer
    LD BC, 0 ; mismo valor que si hubiésemos hecho una fila entera
    EXX ; cambiamos al juego de registros alternativo
    LD HL, buffer + 6144 ; apunta a los atributos de color del buffer
    LD DE, 22528 ;  zona de atributos de la pantalla
    LD BC, 768 ; tamaño de los atributos
 loop1:
     EXX ; volvemos al juego original con los datos de píxeles
     INC B ; como BC aquí vale cero, esto es igual que LD BC, 256
           ; pero más rápido
 loop2:
     POP DE
     LDI
     … ; 32 LDIs en total
     LDI
     JP PE, loop2
     EXX
     LDI
     … ; 32 LDIs en total
     LDI
     JP PE, loop1 ; no podemos usar DJNZ porque el salto es
                   ; de más de 128 bytes
     …
 tabla_direcciones:
     DEFW 0x4000, 0x4100, 0x4200, 0x4300, 0x4400, 0x4500, 0x4600, 0x4700
     DEFW 0x4020, 0x4120, 0x4220, 0x4320, 0x4420, 0x4520, 0x4620, 0x4720
     ; completar hasta las 192 líneas

Ahora el bucle interno dura 11 + 512 + 10 = 533 Tstados, aunque en las zonas con contienda serán 536, por lo que necesitaremos entre 4 264 y 4 288 Tstados por cada fila de caracteres. Sumando la parte de los atributos tenemos que serán 4 + 4 + 4 + 512 + 10 = 534 Tstados extra, que cuando haya contienda subirá a 536 Tstados. Y esto para cada una de las 24 filas de la pantalla, lo que nos da entre 115 152 y 115 776 Tstados, lo que significa que en el peor de los casos estamos igual, pero en el mejor ahorramos algo.

¿Pero realmente existe ese «mejor de los casos» si, al escribir en pantalla, siempre tenemos contienda? En realidad esto sólo es verdad a medias: sólo tenemos contienda cuando la ULA está leyendo de la memoria para pintar el paper, pero no cuando está pintando el borde. Teniendo en cuenta que de las 312 líneas de la pantalla, sólo 192 tienen contienda, y las 192 las recorremos dos veces (pues primero el haz va por delante nuestra, pero al llegar al final de la pantalla y volver al principio va por detrás hasta que nos alcanza) tenemos un total de 504 líneas, de las cuales 120 no tienen contienda. Eso significa que el tiempo real será, aproximadamente, 0,762 * tiempo_peor + 0,238 * tiempo mejor. Por tanto, en este caso, tenemos que tardaremos 115 627 Tstados, frente a los 115 764 Tstados del caso anterior. No es mucha diferencia, pero cualquier Tstado que ahorremos es tiempo que podemos emplear luego en generar el siguiente frame. Y teniendo en cuenta que antes de pintarlo tenemos que sincronizarnos con la pantalla, el pasarnos tan solo un Tstado puede hacer que tengamos que esperar al siguiente frame de la pantalla.