En esta entrada vamos a ver el último punto que nos falta: cómo sincronizarnos con el barrido de la pantalla. Ya comenté en la primera entrada que esto es algo fundamental para asegurarnos de que copiamos todos los datos desde nuestro buffer hasta la memoria de vídeo sin que se produzcan efectos indeseados como parpadeos o tearing.
El primer detalle importante que tenemos que saber es que en el Spectrum, cada vez que se comienza a generar una imagen, la ULA genera una interrupción enmascarable. Por defecto, la ROM pone las interrupciones en Modo 1, lo que hace que el procesador salte a la dirección 0x38 cada vez que se genera una interrupción enmascarable. En esta dirección de la ROM está la rutina que lee el teclado y hace otras operaciones básicas, como incrementar el contador de frames. Por lo tanto, la manera más sencilla de sincronizarnos con el haz es ejecutar una instrucción HALT, que espera a que se produzca una interrupción antes de proseguir la ejecución de instrucciones (¡¡Pero no hay que olvidarse de habilitar antes las interrupciones, o el sistema se quedará colgado!!!). Sin embargo, en este momento el haz estará todavía en la parte superior del BORDER, por lo que tenemos que esperar aún a que llegue a la zona del PAPER.
La primera idea, la más naive, es hacer un bucle de retardo que espere el tiempo exacto que necesita el haz para llegar a la zona del PAPER. Teniendo en cuenta que la parte superior del borde son 64 líneas y que cada una dura 224 Testados, sólo tenemos que esperar 14 336 Testados desde el instante posterior al HALT.
Sin embargo, en la práctica esto no es una buena idea por dos motivos: para empezar, en los modelos de 128Kbytes cada línea dura 228 Testados, por lo que tendríamos que detectar si estamos en un equipo de 48 o de 128Kbytes y, según el resultado, esperar 14 336 o 14 592 Testados. Esperar siempre el tiempo máximo tampoco es bueno, porque entonces en los modelos de 48Kbytes desperdiciamos 256 Testados, que son dos scanlines completas.
Por otro lado, no hay que olvidar que la instrucción HALT continuará la ejecución después de que se haya ejecutado la rutina de interrupción, la cual tardará un tiempo desconocido. Eso significa que estaremos perdiendo una vez más un valioso tiempo que necesitamos utilizar para pintar, no para desperdiciar.
Es por esto necesitamos algún método para saber con precisión cuando el barrido ha llegado al PAPER realmente. Afortunadamente, esta vez el que el Spectrum se diseñase para ser lo más barato posible nos ofrece la solución. Echemos un vistazo a cómo está conectada la CPU y la ULA a la memoria:
La imagen muestra únicamente la conexión del bus de datos, pues es la parte que nos interesa. Si nos fijamos, vemos que el bus de datos de la ULA está conectado directamente a la RAM de vídeo. Por su parte, el bus de datos del Z80 está conectado directamente sólo a la RAM superior (bueno, y a la ROM y al bus trasero, pero no me apetecía pintarlo todo). Esto significa que la ULA tiene prioridad absoluta a la hora de trabajar con la RAM de vídeo, mientras que el Z80 la tiene al trabajar con la RAM superior, la ROM y todo aquello que esté conectado al bus externo. Pero, y esta es la clave, el bus de datos del Z80 está conectado al bus de datos de la RAM de vídeo (y de la ULA) a través de un conjunto de resistencias de 330 ohmios. Estas resistencias permiten aislar ambos buses cuando la ULA accede a la memoria RAM a la vez que el Z80 accede a la ROM, a la RAM superior o a un periférico externo. En efecto, las resistencias absorben la diferencia de potencial cuando en un lado del bus hay un 1 (5 voltios) y en el otro hay un 0 (0 voltios), de manera que cada lado del bus puede tener un dato diferente sin riesgo de cortocircuitos. Sin embargo, a la vez, permiten que el Z80 lea los datos de la RAM de vídeo cuando la ULA no está accediendo a ella, pues si no hay ningún periférico que imponga un valor en el bus de datos del Z80, la corriente atravesará las resistencias y el valor que haya en el bus de datos de la ULA/RAM de vídeo será visible en el bus de datos del Z80. Y ésta es precisamente la clave: si leemos del bus de datos sin activar ningún periférico, podremos ver qué está leyendo la ULA en ese momento. La cuestión es que la ULA sólo lee de la RAM de vídeo cuando está pintando el PAPER, pues cuando pinta el BORDER utiliza un valor almacenado internamente, con lo cual, la clave para saber cuando la ULA ha empezado a pintar el PAPER es leer repetidamente el bus sin acceder a ningún dispositivo: si la ULA no está leyendo la RAM de vídeo, el bus no estará conectado a nada, con lo que leeremos 255 (por la tecnología utilizada en el Z80, cuando una entrada está al aire, se lee un 1 lógico, por lo que si las ocho líneas del bus de datos están sin conectar a nada, estarán todas a 1 y leeremos 255), mientras que si la ULA está leyendo, leeremos el dato de la RAM de vídeo correspondiente: bien el byte de píxeles que se va a pintar, bien el byte de atributos de color correspondiente. Obviamente, si cometemos la torpeza de llenar las primeras líneas de la memoria de vídeo con el valor 255 (sí, yo lo hice 😀 ) retrasaremos la detección, por lo que es recomendable evitar ese valor al menos en la primera fila (aunque como veremos luego, tampoco es tan desastroso si lo ponemos sólo en los píxeles, pero no en los atributos).
Ahora llega la siguiente cuestión: ¿cómo podemos leer el bus sin que ningún dispositivo intente meter un dato? No podemos hacerlo leyendo de la RAM, salvo que estemos en un Spectrum 16K, pues si leemos en los primeros 16Kbytes nos responderá la ROM, si leemos en los siguientes 16Kbytes nos responderá la RAM de vídeo (y, además, sólo cuando la ULA no esté leyendo nada), y si leemos los 32Kbytes últimos nos responderá la RAM superior. Ante esto, la única solución es leer de un puerto de entrada/salida (E/S) en el que sepamos que no hay ningún periférico.
Por convenio, en el Spectrum se utilizan los ocho bits inferiores del bus de direcciones para direccionar periféricos, y para simplificar la circuitería se le asigna un único bit a cada uno, que debe ponerse a cero para seleccionar el dispositivo. Así, el bit A0 selecciona la ULA como periférico, el bit A1 la impresora ZX, el bit A2 se reservó por Sinclair en los modelos de 48Kbytes, y se utiliza para la paginación y el chip de sonido en los modelos de 128Kbytes; los bits A3 y A4 son para la interfaz 1, y los bits 5, 6 y 7 están disponibles para otros periféricos (por ejemplo, el bit 5 direcciona la interfaz Kempston para joysticks). Por supuesto, no debemos poner más de uno de estos bits a cero, o correremos el riesgo de provocar un cortocircuito y que el ordenador se resetee.
Sabiendo esto, es fácil deducir que hay que evitar poner cualquiera de esos bits a cero para que, de esa manera, ningún periférico intente darnos sus datos y así que no fuercen un valor del bus; por tanto, tenemos que leer del puerto 255 (todos los bits del bus de direcciones a 1… o al menos los ocho bits inferiores). Como no hay ningún periférico que responda a esa dirección, se leerá el valor del bus al aire, y eso incluirá lo que sea que la ULA esté leyendo de la RAM de vídeo (si está leyendo en ese momento, claro).
Así, en principio bastaría con esperar por una interrupción para saber que estamos en la parte superior de la pantalla, y entonces entrar en un bucle que lea repetidamente de dicho puerto 255 hasta que el valor leído sea diferente de 255, momento en el que sabremos que la ULA ha empezado a pintar el PAPER y, por tanto, podemos empezar a copiar los datos del buffer a la memoria de vídeo. Una posible rutina sería esta:
ld B, 255 halt loop: in A, (255) cp B jp NZ, loop
Por desgracia, ahora vienen los detalles escabrosos, y es que, para empezar, la rutina anterior sólo hace una lectura cada 25 Testados, lo que significa que si tenemos la mala suerte de que haga una lectura justo antes de que empiece a leer datos, tendremos que esperar al menos 25 ciclos de reloj antes de poder volver a leer el bus. Pero esto no es todo, pues, además, la ULA no está leyendo constantemente de la RAM. Si recordamos lo que vimos en la entrada 4, la ULA aprovechaba una característica de las RAMs dinámicas para leer dos posiciones de memoria (píxeles y atributos de color) en tres ciclos de reloj en lugar de los cuatro necesarios normalmente, y además agrupaba dos grupos de lecturas juntas de manera que en seis ciclos seguidos leía los datos (píxeles y atributos de color) de dos caracteres consecutivos, dejando así libres dos ciclos de reloj consecutivos para que el procesador pueda acceder a la RAM de vídeo mientras la ULA está pintando el PAPER. La cuestión es que de esos tres ciclos, sólo los dos últimos tendrán un dato de la RAM, mientras que el primero no tendrá nada (y, por tanto, dejará el bus a 255). En otras palabras: de cada ocho Testados, sólo el segundo, tercero, quinto y sexto pondrán un valor diferente a 255 en el bus. En el primero y en el cuarto la ULA estará enviando la parte inferior de la dirección de memoria a leer, y el séptimo y el octavo son los dos ciclos en los que el Z80 podría acceder al RAM de vídeo para leer o escribir cuando la ULA no está pintando el borde. Esto significa que, por si fuera poco, la ULA podría estar ya pintando el PAPER pero nosotros no detectarlo a la primera porque hemos tenido la mala suerte de que la instrucción IN se ejecutó justo en alguno de esos cuatro ciclos de reloj en los que la memoria RAM de vídeo no está enviando datos. Pero aún peor: si el periodo entre lecturas es múltiplo de 4, habrá algunos casos en los que jamás podremos detectar si la ULA está pintando el PAPER: en efecto, supongamos que tenemos un bucle que lee cada 28 ciclos (que es un múltiplo entero de 4); si tenemos la mala suerte de que la instrucción IN se ejecute justo en el cuarto ciclo de un proceso de lectura de la ULA, el procesador leerá 255 pues en ese momento la ULA está enviando la parte baja de la dirección de memoria a leer, no está leyendo aún; pero 28 ciclos después estará en el octavo ciclo del grupo de ocho, que está libre para el Z80 y no hay acceso por parte de la ULA; 28 ciclos después volverá a estar en el ciclo 4… y así ad finitum. Por tanto, es necesario que el periodo del bucle de lectura y el periodo de acceso de la ULA sean primos relativos.
Sin embargo, incluso unos primos son mejores que otros; y es que por las características de las operaciones modulares, puede ocurrir que un valor más pequeño sea peor que uno mayor. Por tanto ¿qué periodo de lectura será el óptimo, teniendo en cuenta que no podemos hacer que dure menos de 25 Testados? ¿Será 25 el valor óptimo, o un valor mayor puede ser mejor? Por desgracia, si tenemos en cuenta que, a priori, no sabemos si vamos a necesitar más de una línea para detectar si la ULA está leyendo, y que la ULA sólo lee durante 128 Testados que dura el PAPER, y el resto hasta los 224 Testados que dura cada línea no lee nada, la cosa se complica, así que decidí hacer un programita que me calcule cuantos Testados como máximo puedo tardar en detectar una lectura de la ULA (y ya de paso, cuantos en promedio) con cualquier periodo de lectura, y este es el resultado:
Periodo: 6; Maximo: 11; Promedio: 4.5 Periodo: 5; Maximo: 12; Promedio: 4.0 Periodo: 7; Maximo: 20; Promedio: 7.0 Periodo: 10; Maximo: 25; Promedio: 9.5 Periodo: 9; Maximo: 32; Promedio: 11.0 Periodo: 14; Maximo: 35; Promedio: 13.5 Periodo: 11; Maximo: 40; Promedio: 14.0 Periodo: 13; Maximo: 44; Promedio: 15.0 Periodo: 18; Maximo: 49; Promedio: 18.5 Periodo: 15; Maximo: 52; Promedio: 18.0 Periodo: 22; Maximo: 59; Promedio: 22.5 Periodo: 17; Maximo: 64; Promedio: 22.0 Periodo: 19; Maximo: 72; Promedio: 25.0 *Periodo: 26; Maximo: 73; Promedio: 27.5 Periodo: 21; Maximo: 76; Promedio: 26.0 Periodo: 30; Maximo: 83; Promedio: 31.5 Periodo: 23; Maximo: 84; Promedio: 29.0 *Periodo: 25; Maximo: 96; Promedio: 33.0 Periodo: 34; Maximo: 97; Promedio: 36.5 Periodo: 27; Maximo: 104; Promedio: 36.0 Periodo: 38; Maximo: 107; Promedio: 40.5 Periodo: 29; Maximo: 108; Promedio: 37.0 Periodo: 31; Maximo: 116; Promedio: 40.0 Periodo: 42; Maximo: 121; Promedio: 45.5 Periodo: 33; Maximo: 227; Promedio: 47.0 Periodo: 35; Maximo: 241; Promedio: 53.0 Periodo: 37; Maximo: 251; Promedio: 54.0
Tal y como sospechaba, vemos que utilizar un periodo de 25 Testados es mucho peor que uno de 26 (ambos marcados con un asterisco a la izquierda), pues con esta última perderemos un máximo de 73 Testados en detectar una lectura de la ULA, y un promedio de 27,5 Testados, mientras que con un periodo de 25 Testados podemos llegar a tardar hasta 96 Testados y un promedio de 33 Testados. Por tanto, tenemos que sustituir IN A, (255), que consume 11 Testados, por IN A, (C), que consume 12 Testados, e inicializar BC fuera del bucle a 0xFFFF:
ld BC, 0xFFFF halt loop: in A, (C) cp B jp NZ, loop
Así pues, colocando este pequeño bucle justo antes de la rutina de copia que vimos en las tres primeras entradas, la sincronizaremos perfectamente con el haz y empezaremos a copiar como mucho 73 Testados más tarde de que la ULA haya empezado a pintar. Pero lo mejor es que podemos, si queremos, aprovechar parte de los 14 336 Testados que hay entre la interrupción y ese momento para realizar otras tareas, como por ejemplo reproducir música simultáneamente, y todo ello sin tener que preocuparnos de calcular con precisión cuanto vamos a tardar (siempre y cuando estemos seguros de tardar menos de 14 336 Testados, claro), pues el bucle anterior nos sincronizará.
¿Y qué ocurre si cometemos el error de poner los bytes de píxeles a 255, y sólo los de los atributos son diferentes de 255? En ese caso, con un bucle que dure 26 Testados tardaremos un máximo de 99 Testados en detectar el PAPER y un promedio de 49,5 Testados. Tampoco es exactamente una catástrofe.
El Inves Spectrum Plus y el Plus 2A/Plus 3
Por último, añadir un par de comentarios extra; el primero sobre un ordenador muy especial: el Inves Spectrum Plus. Este equipo se lanzó al mercado en 1986 y no era de Sinclair, sino de la empresa española Investrónica. Se trata de un modelo que intenta ser compatible con el ZX Spectrum original, pero que por desgracia tiene algunas diferencias que hacen que no lo sea del todo.
La primera gran diferencia es que no tiene contienda de memoria. El sistema que utiliza para conseguirlo es realmente ingenioso, y lo explican muy bien Miguel Ángel Rodríguez Jódar y César Hernández Bañó en un artículo que escribieron donde analizan el hardware del Inves Spectrum Plus en profundidad. La ventaja de esto es que este ordenador es más rápido que el Spectrum original a pesar de trabajar a la misma velocidad de reloj. Por desgracia, la implementación de este sistema hace que el Z80 nunca pueda ver los accesos a memoria de la ULA, sino que el bus de datos siempre valdrá 255 cuando ningún periférico ponga un dato en el. Eso significa que el bucle anterior sencillamente se colgará.
Afortunadamente hay un segundo cambio que viene en nuestra ayuda, y es que la interrupción ya no se genera al comenzar un cuadro, sino justo cuando se va a comenzar a pintar el PAPER. Esto significa que, en un Inves, hay que eliminar completamente el bucle y empezar a copiar el buffer justo después del HALT.
En el caso del Plus 3 (y del Plus 2A, con quien comparte placa), la situación es peor, pues se ha eliminado completamente el efecto del bus flotante. Afortunadamente hay una manera sencilla de resolver el problema, tal y como se explica en este artículo que restaura el bus flotante en el Plus 3. Es importante recalcar que sólo lo restaura en el bit 7. Dado que este bit, en el byte de atributos de color, activa el FLASH, en general estará a cero, lo que garantiza que al menos se puede detectar una de las dos lecturas en caso de que todos los bytes de píxeles tengan el bit 7 a uno. De hecho es muy posible que este truco se pueda implementar en el Inves Spectrum también.
Un ejemplo
Después de la teoría, ha llegado la hora de juntarlo todo y hacer una microdemo. Y aquí está:
Partí de unos gráficos inspirados en Among Us para hacer un pequeño personajillo, y añadí una baldosa. Como vemos, no sólo se pintan los píxeles sino también los atributos de color, y todo ello de manera perfectamente sincronizada con el haz para conseguir un movimiento sin tearing. La velocidad que alcanza es algo más de ocho FPS haciendo un scroll de pantalla-cuasi-completa con color, lo que no está nada mal.
Para comparar, vamos a quitar la sincronización con el haz de electrones:
Vemos que en varios puntos se producen efectos de tearing que deslucen mucho el resultado.
En el primer vídeo vemos que sólo pinto 20 líneas en lugar de 22. El motivo es que, a pesar de todo, la última fila de atributos de color de esas 22, no siempre da tiempo a pintarla correctamente, así que ya puestos, opté por redondear a un múltiplo del tamaño de la baldosa (que es de 4×4 caracteres), de manera que quede una zona para el inventario de objetos recogidos por el usuario. Sí, porque… ¡¡¡voy a intentar convertir todo esto en un juego!!!