Posiciones de pantalla ZX Spectrum

La pantalla del ZX Spectrum tiene una resolución de 256*192 píxeles, o lo que es lo mismo 32 columnas y 192 scanlines. Con cada byte podemos configurar 8 píxeles, encendiendo los que están a 1 y apagando los que están a 0; 256 píxeles / 8 píxeles por byte = 32 columnas. El área gráfica ocupa 6.144 bytes ($1800) y comienza en la dirección de memoria 16.384 ($4000).

Una de las particularidades del ZX Spectrum es la forma en la que se distribuye el área gráfica. Cada carácter se compone de 64 píxeles, o lo que es lo mismo 8×8. A cada una de las líneas del carácter se la denomina scanline. Si pintamos en el primer scanline de un carácter y luego sumamos 32 a la posición de memoria, la lógica nos dice que nos situamos en el siguiente scanline del carácter, pero no es así ya que en realidad nos estamos situando en el primer scanline del carácter situado justo debajo del que estamos.

Si pintamos la posición $4000, primer byte de la pantalla, y luego queremos pintar en el primer scanline de la línea 8 de pantalla, la lógica dice que solo habría que sumar 8 veces 32 ($20) a la posición $4000, por lo que el primer scanline del primer carácter de la línea 8 estaría en la posición $4100. Pero esto no es así ya que la posción $4100 corresponde con el segundo scanline del primer carácter de la pantalla.

Este comportamiento es debido a que la pantalla del ZX Spectrum está dividida en tercios. Cada uno de los tercios tiene 8 líneas y cada una de las líneas 8 scanlines. El primer tercio va desde la posición de memoria $4000 hasta la $47FF, el segundo tercio desde la $4800 hasta la $4FFF y el tercer tercio desde la $5000 hasta la $57FF.

Aunque en principio esto resulte extraño, cuando se ve la composición que toman los bytes a la hora de conformar una posición de memoria, todo cobra sentido. La composición de los bytes es la siguiente:

010T TSSS LLLC CCCC

Donde 010 es siempre fijo, TT representa el tercio (de 0 a 2), SSS representa el scanline dentro del carácter (de 0 a 7), LLL representa la línea dentro del tercio (de 0 a 7) y CCCCC representa la columna (de 0 a 31).

Si nos fijamos en la línea, de 0 a 23, los bits 0, 1 y 3 siempre indican la línea dentro del tercio, y los bits 4 y 5 el tercio.

Línea 0:  0000 0000     Línea 0 del tercio 0
Línea 7: 0000 0111 Línea 7 del tercio 0
Línea 8: 0000 1000 Línea 0 del tercio 1
Línea 15: 0000 1111 Línea 7 del tercio 1
Línea 16: 0001 0000 Línea 0 del tercio 2
Línea 23: 0001 0111 Línea 7 del tercio 2

Para ver todo lo expuesto, vamos a realizar un sencillo ejemplo que rellena los bytes de la primera columna con un secillo patrón $AA (1001 1001).

org $8000 

ld hl, $4000 ; HL = dirección de inicio de la VideoRAM.
ld bc, $20 ; BC = valor a sumar para pasar al siguiente
; carácter.
ld a, $08 ; A = para dibujar el primer scanline del primer
; tercio.

loop:
ld (hl), $aa ; Carga en pantalla el patrón $AA.
add hl, bc ; Avanza HL a la siguiente línea.
dec a ; Decrementa A.
jr nz, loop ; Bucle hasta que A llegue a 0.

ret

Si ejecutamos el programa, dibuja el patrón $AA en el primer scanline de las 8 primeras líneas.

Para ver la distribución de la pantalla del ZX Spectrum, solo hay que ir cambiado la línea LD A, $08 e ir cargando múltiplos de $08; $08, $10, $18, $20, $28, $30, $38, $40, $48…

Hasta que no lleguemos a $48, no se verá como se pinta el primer scanline de las 8 líneas del segundo tercio.

A partir de aquí, vamos a ir viendo rutinas que os pueden ayudar a la hora de trabajar con la pantalla del ZX Spectrum. Tened en cuenta que estas rutinas no controlan si las coordenadas están fuera del área de la pantalla.

La rutina CharCoord2PointerHR, toma unas coordenadas X e Y y retorna la dirección de memoria correspondiente.

; ----------------------------------------------------------------
; CharCoord2PointerHR: Obtiene la dirección de memoria 
; correspondiente a las coordenadas de carácter especificadas. 
; 
; Entrada: B -> Coordenada Y (0 a 23).
;          C -> Coordenada X (0 a 31).
; 
; Salida: HL -> Dirección de memoria que corresponde a las
;               coordenadas. 010T TSSS LLLC CCCC. 
;
; Altera el valor de los registros AF y HL.
; ----------------------------------------------------------------- CharCoord2PointerHR:
ld a, b       ; A = línea. Bits 0, 1 y 2 línea. Bits 3 y 4 tercio.
and $18       ; Se queda con el tercio 0001 1000. 
              ; Scanline siempre a 0.
or $40         ; Agrega la parte fija 0100 0000.
ld h, a       ; H = A. Parte alta de la dirección calculada.

ld a, b       ; A = línea.
and $07       ; Se queda con la línea dentro del tercio.
rrca
rrca
rrca          ; Rota tres veces para pasar valor a bits 5, 6 y 7.
or c          ; Agrega la columna.
ld l, a       ; L = A. Parte baja de la dirección calculada.

ret

Para probar esta rutina podemos ir pasándole posiciones y luego cargando un patrón en la dirección devuelta y así ver donde dibuja.

org $8000

ld b, $10 ; Carga en B la línea.
ld c, $10 ; Carga en C la columna.
call CharCoord2PointerHR ; Calcula la posición de memoria.
ld (hl), $ff ; Activa todos los píxeles en la
; posición de memoria.

inc b ; Incrementa la línea.
inc c ; Incrementa la columna.
call CharCoord2PointerHR ; Calcula la posición de memoria.
ld (hl), $ff ; Activa todos los píxeles en la
; posición de memoria.

inc b ; Incrementa la línea.
dec c ; Decrementa la columna.
call CharCoord2PointerHR ; Calcula la posición de memoria.
ld (hl), $ff ; Activa todos los píxeles en la
; posición de memoria.

ret

La rutina PointerHR2CharCoord, toma una dirección de memoria y retorna las coordenadas de carácter correspondientes.

; ---------------------------------------------------------------- 
; PointerHR2CharCoord: Obtiene las coordenadas de carácter
; correspondientes a la dirección de memoria especificada.
;
; Entrada: HL -> Puntero a memoria. 010T TSSS LLLC CCCC.
;
; Salida: B -> Coordenada Y (0 a 23).
; C -> Coordenada X (0 a 31).
;
; Altera el valor de los registros AF y BC.
; ---------------------------------------------------------------- PointerHR2CharCoord:
ld a, h ; A = parte alta de la dirección de memoria. 010T TSSS.
and $18 ; Se queda con el tercio. Bits 4 y 5 de la
; coordenada Y. Scanline siempre a 0, son coordenadas
; de carácter.
ld b, a ; B = A.

ld a, l ; A = parte baja de la dirección de memoria. LLLC CCCC.
and $e0 ; Se queda con la línea dentro del tercio.
rrca
rrca
rrca ; Rota tres veces para pasar valor a bits 0, 1 y 3.
or b ; Agrega los bits 4 y 5.
ld b, a ; B = A. Coordenada Y calculada.

ld a, l ; A = parte baja de la dirección de memoria. LLLC CCCC.
and $1f ; Se queda con la columna.
ld c, a ; C = A. Coordenada X calculada.

ret

La rutina PointerHRNextLine, toma una dirección de memoria y retorna la dirección de memoria correspondiente a la línea siguiente.

; ----------------------------------------------------------------
; PointerHRNextLine: obtiene la dirección de memoria
; correspondiente a la siguiente línea de carácter.
;
; Entrada: HL -> Dirección actual. 010T TSSS LLLC CCCC.
;
; Salida: HL -> Dirección de la línea siguiente.
; 010T TSSS LLLC CCCC.
;
; Altera el valor de los registros AF y HL.
; ---------------------------------------------------------------- PointerHRNextLine:
ld a, l ; A = parte baja de la dirección. LLLC CCCC.
add a, $20 ; Añade una línea (LLLC CCCC + 0010 0000).
ld l, a ; L = A.
ret nc ; Si no hay acarreo, la línea sigue entre 0 y 7, sale.

; Hay acarreo, hay que cambiar el tercio de la pantalla.
ld a, h ; A = parte alta de la dirección. 010T TSSS.
add a, $08 ; Añade un tercio (010T TSSS + 0000 1000).
ld h, a ; H = A.

ret

La rutina PointerHRPreviousLine, toma una dirección de memoria y retorna la dirección de memoria correspondiente a la línea anterior.

; ----------------------------------------------------------------
; PointerHRPreviousLine: obtiene la dirección de memoria
; correspondiente a la línea anterior de carácter.
;
; Entrada: HL -> Dirección actual. 010T TSSS LLLC CCCC.
;
; Salida: HL -> Dirección de la línea anterior.
; 010T TSSS LLLC CCCC.
;
; Altera el valor de los registros AF y HL.
; ---------------------------------------------------------------- PointerHRPreviousLine:
ld a, l ; A = parte baja de la dirección. LLLC CCCC.
and $e0 ; Se queda con la parte de la línea (1110 0000).
jr z, PointerHRPreviousLine_continue ; Si línea es 0,
; cambio de tercio.

; No hay cambio de tercio.
ld a, l ; A = parte baja de la dirección, LLLC CCCC.
sub $20 ; Resta una línea (LLLC CCCC - 0010 0000).
ld l, a ; L = A. Dirección calculada.

ret

PointerHRPreviousLine_continue:
; Hay que cambiar el tercio.
ld a, l ; A = parte baja de la dirección. LLLC CCCC.
or $e0 ; Pone la línea a 7 (LLLC CCCC or 1110 0000).
ld l, a ; L = A. Parte baja de la dirección calculada.

ld a, h ; A = parte alta de la dirección. 010T TSSS.
sub $08 ; Resta uno al tercio (010T TSSS - 0000 1000).
ld h, a ; H = A. Parte baja de la dirección calculada.

ret

La rutina PointerHRNextScanLine, toma una dirección de memoria y devuelve la del siguiente scanline.

; ----------------------------------------------------------------
; PointerHRNextScanLine: obtiene la dirección de memoria
; correspondiente al siguiente scanline.
;
; Entrada: HL -> dirección actual. 010T TSSS LLLC CCCC.
;
; Salida: HL -> dirección del siguiente scanline.
; 010T TSSS LLLC CCCC.
;
; Altera el valor de los registros AF y HL.
; ---------------------------------------------------------------- PointerHRNextScanLine:
ld a, h ; A = parte alta de la dirección. 010T TSSS.
and $07 ; Se queda con el scanline.
cp $07 ; Comprueba si el scanline es 7.
jr z, PointerHRNextScanLine_continue ; Es 7, cambio de línea.

; El scanline no es 7.
inc h ; Aumenta en 1 el scanline y sale.

ret

PointerHRNextScanLine_continue:
; Hay que cambiar la línea.
ld a, l ; A = parte baja de la dirección. LLLC CCCC.
add a, $20 ; Añade una línea (LLLC CCCC + 0010 0000).
ld l, a ; L = A.
ld a, h ; A = parte alta de la dirección. 010T TSSS.
jr nc, PointerHRNextScanLine_end ; Si no hay acarreo, salta
; para terminar el cálculo.

; Hay acarreo, hay que cambiar el tercio.
add a, $08 ; Añade uno al tercio (010T TSSS + 0000 1000).

PointerHRNextScanLine_end:
and $f8 ; Se queda con la parte fija y el tercio.
; Deja el scanline a 0.
ld h, a ; H = A. Dirección calculada.

ret

Para probar la rutina, vamos a utilizar un pequeño programa para ver como dibuja. Al contrario de lo que vimos en el primer ejemplo, ahora si se pinta seguido, un scanline seguido del que va justo detrás.

org $8000

ld hl, $4000 ; HL = dirección de inicio de la VideoRAM.
ld b, $08 ; B = para dibujar 8 scanlines.

loop:
ld (hl), $aa ; Carga en pantalla el patrón $AA.
call PointerHRNextScanLine ; Avanza al siguiente scanline.
djnz loop ; Bucle hasta que B llegue a 0.

ret

Si ejecutamos el programa, dibuja el patrón $AA en el primer carácter. El primer ejemplo lo hacía en el primer scanline de las 8 primeras líneas.

Al igual que hicimos en el primer ejemplo, podemor ir cargando múltiplos de $08 en B para ver como va dibujando el programa; $08, $10, $18, $20, $28, $30, $38, $40, $48…

Por último, la rutina PointerHRPreviousScanLine, toma una dirección de memoria y retorna la dirección de memoria correspondiente al scanline anterior.

; ----------------------------------------------------------------
; PointerHRPreviousScanLine: obtiene la dirección de memoria
; correspondiente al scanline anterior.
;
; Entrada: HL -> dirección actual. 010T TSSS LLLC CCCC.
;
; Salida: HL -> Dirección del scanline anterior.
; 010T TSSS LLLC CCCC.
;
; Altera el valor de los registros AF y HL.
; ---------------------------------------------------------------- PointerHRPreviousScanLine:
ld a, h ; A = parte alta de la dirección. 010T TSSS.
and $07 ; Se queda con el scanline.
or a ; Comprueba si está a 0.
jr z, PointerHRPreviousScanLine_continue ; Si es 0, cambiar línea.

; No hay cambio de línea.
dec h ; Decrementa el scanline y sale.

ret

PointerHRPreviousScanLine_continue:
ld a, l ; A = parte baja de la dirección. LLLC CCCC.
and $e0 ; Se queda con la parte de la línea.
or a ; Comprueba si está a cero.
jr z, PointerHRPreviousScanLine_change ; Si es 0, cambiar tercio.

; No hay que cambiar tercio.
ld a, l ; A = parte baja de la dirección. LLLC CCCC.
sub $20 ; Resta un línea, (LLLC CCCC - 0010 0000).
ld l, a ; L = A.
jr PointerHRPreviousScanLine_end ; Salta a fin de cálculo.

PointerHRPreviousScanLine_change:
; Hay que cambiar de tercio.
ld a, l ; A = parte baja de la dirección. LLLC CCCC.
or $e0 ; Pone la línea en 7.
ld l, a ; L = A.

ld a, h ; A = parte alta de la dirección. 010T TSSS.
sub $08 ; Resta uno al tercio.
ld h, a ; H = A.

PointerHRPreviousScanLine_end:
ld a, h ; A = parte alta de la dirección. 010T TSSS.
and $F8 ; Se queda con la parte fija y el tercio.
or $07 ; Pone el scanline a 7.
ld h, a ; H = A.

ret

Y para poder probar la rutina, otro sencillo programa.

org $8000

ld hl, $57ff ; HL = dirección de fin de la VideoRAM.
ld b, $08 ; B = para dibujar 8 scanlines.

loop:
ld (hl), $aa ; Carga en pantalla el patrón $AA.
call PointerHRPreviousScanLine ; Retrocede al scanline anterior.
djnz loop ; Bucle hasta que B llegue a 0.

ret

Todo lo que he puesto aquí, lo he aprendido siguiendo el curso de ensamblador que se encuentra en speccy.org y leyendo el libro Código Máquina del ZX-Spectrum de Jesús Alonso Cano.

Las rutinas aquí expuestas son los ejercicios que he realizado para practicar lo aprendido.

Numeros BCD

Los números BCD se ven representados de manera natural, tal y como nosotros los veríamos; el 99 hexadecimal, representa al 99 decimal.

Para conseguir esto, un byte puede representar números del 0 al 99, dividiendo el byte en dos nibbles (4 bits). Cada nibble puede representar números del 0 a 9.

De esta manera, el 10 hexadecimal, que en decimal es 16, en BCD representa el 10 decimal. Dicho de otra manera, vemos los números hexadecimales como si de números decimales se tratara.

Para representar un número de 10 dígitos, se necesitan 5 bytes. Se puede usar un byte extra para indicar la posición de la coma, y así poder trabajar con decimales. Los números BCD se usan para operar con cifras de más de 16 bits.

A la hora de operar con números BCD, hay que tener muy presente la instrucción DAA (Decimal Adjust Accumulator). DAA realiza ajustes en los resultados de operaciones con números BCD, y funciona de la siguiente manera:

  • Comprueba los bits 0, 1, 2 y 3. Si contienen un dígito no BCD (mayor de 9) o se establece el flag H; suma o resta, dependiendo de la operación, 06h (0000 0110b) al byte.
  • Comprueba los bits 4, 5, 6 y 7. Si contienen un dígito no BCD (mayor de 9) o se establece el flag C; suma o resta, dependiendo de la operación, 60h (0110 0000b) al byte.

De esta manera, si tenemos $09 y sumamos $01, el resultado es $0A. Tras ejecutar DAA, el resultado es $10. Después de cada instrucción aritmética, incremento o decremento hay que ejecutar DAA.

ld a, $09     ; A = $09
inc a ; A = $0A
daa ; A = $10

dec a ; A = $0F
daa ; A = $09

add a, $03 ; A = $0C
daa ; A = $12

sub $03 ; A = $0F
daa ; A = $09

Por otro lado, la manera de obtener el código ASCII de cada nibble, es relativamente sencilla.

La rutina que se muestra a continuación, recibe un número BCD en el registro B y lo muestra en pantalla.

; -----------------------------------------------------
; Imprime en pantalla un número BCD.
; Entrada: B -> Número BCD a imprimir.
; Altera el valor del registro AF.
; -----------------------------------------------------
PrintBCD:
ld a, b ; Carga en A el número a imprimir
and $f0 ; Se queda con el nibble superior
rrca
rrca
rrca
rrca ; Lo rota cuatro veces para pasarlo al nibble inferior
add a, '0' ; Suma el ASCII del 0 para obtener el ASCII del número
rst $10 ; Muestra el número en pantalla (Spectrum)
;call $bb5a ; Muestra el número en pantalla (Amstrad CPC)

ld a, b ; Vuelve a cargar el número a imprimir en A
and $0f ; Se queda con el nibble inferior
add a, '0' ; Suma el ASCII del 0 para obtener el ASCII del número
rst $10 ; Muestra el número en pantalla (Spectrum)
;call $bb5a ; Muestra el número en pantalla (Amstrad CPC)

ret ; Sale de la rutina

RST $10, en ZX Spectrum, imprime en pantalla el carácter correspondiente al código Ascii cargado en el registro A.

CALL $bb5a, hace lo propio en Amstrad CPC.

Tres En Raya

En este caso se trata de un programa desarrollado primeramente en Basic, y más tarde trasladado a ensamblador.

El programa original está en el libro Club de programación de juegos de ZX Spectrum, cuya práctica es el origen de Tres En Raya.

Tres En Raya tiene modo de uno o dos jugadores, lo que implica el desarrollo de inteligencia artificial, aunque muy simple.

Tres en raya

Se puede elegir entre uno o dos jugadores, los puntos por partida y los segundos disponibles por cada turno.

El siguiente paso era realizar la versión en ensamblador, y aquí está. De paso aprovecho para ir aprendiendo.

Aquí puedes descargar el código fuente y los programas tanto de la versión Basic, como de la versión ensamblador.

Para la versión Basic, te recomiendo el programa BASinC Emulator, que puedes descargar de manera gratuita, para ver mejor, modificar o depurar el código fuente.

También puedes abrir el archivo de código fuente con cualquier editor de texto, como Notepad++, VS Code, etc.

En la versión ensamblador podrás encontrar el fichero Index.txt, que enumera los ficheros que componen el proyecto, las etiquetas y dónde encontrarlas. Esta versión tambien tiene algo más de sonido.

Espero que el trabajo realizado os pueda aportar algo, ya sea unos momentos de diversión o aprender alguna cosilla.

Música Maestro

Mientras desarrollaba Batalla Espacial, vi la necesidad de incluir algún sonido que acompañara el movimiento de los enemigos, el disparo y la explosión de la nave. Continuar leyendo «Música Maestro»

Batalla Espacial

Batalla Espacial es el primer juego que he desarrollado en ensamblador para ZX Spectrum. Continuar leyendo «Batalla Espacial»