Ensamblador para ZX Spectrum – Pong: $0C sonido y optimización
Llegamos a la recta final, estamos acabando.

Ensamblador para ZX Spectrum – Pong: Paso 10, sonido y optimización
Sí, como dijimos en el entrega anterior, la bola va algo lenta. Esto es debido, en gran parte, a que el marcador se repinta en cada iteración del bucle principal, lo cual no es necesario.
Optimización
El marcador solo se debería repintar cuando es borrado por la bola. Modificando este aspecto, vamos a ganar velocidad en la bola, ya que el tiempo de proceso en cada iteración del bucle principal se va a reducir.
Como es costumbre, creamos la carpeta Paso10 y copiamos los archivos Controls.asm, Game.asm, Main.asm, Sprite.asm y Video.asm desde la carpeta Paso09.
Lo primero es localizar el área de la pantalla dónde la bola borra el marcador, definiendo una serie de constantes en el archivo Sprite.asm, justo debajo de la constante POINTS_P2:
POINTS_X1_L: EQU $0c POINTS_X1_R: EQU $0f POINTS_X2_L: EQU $10 POINTS_X2_R: EQU $13 POINTS_Y_B: EQU $14
El significado de estas constantes, en orden de aparición, es:
- Columna en la que la bola empieza a borrar el marcador del jugador 1 por la izquierda.
- Columna en la que la bola empieza a borrar el marcador del jugador 1 por la derecha.
- Columna en la que la bola empieza a borrar el marcador del jugador 2 por la izquierda.
- Columna en la que la bola empieza a borrar el marcador del jugador 2 por la derecha.
- Tercio, línea y scanline en la que la bola empieza a borrar el marcador por la parte de abajo.
Una vez que hemos definido estas constantes, vamos a modificar las rutinas PrintPoints y ReprintPoints del archivo Video.asm, empezando por localizar la etiqueta printPoint_print, que vamos a sustituir por PrintPoint.
Dentro de la rutina PrintPoints, hay tres llamadas a printPoint_print, que vamos a sustituir por PrintPoint.
Compilamos, cargamos en el emulador y comprobamos que no hemos roto nada.
El siguiente paso es modificar la rutina ReprintPoints. En realidad, no lo vamos a modificar, la vamos a borrar y a volver a implementar:
ReprintPoints: ld hl, (ballPos) call GetPtrY cp POINTS_Y_B ret nc
Cargamos la posición de la bola en HL, LD HL, (ballPos), obtenemos tercio, línea y scanline de la posición de la bola, CALL GetPtrY, y lo comparamos con la posición donde la bola empieza a borrar el marcador desde abajo, CP POINTS_Y_B. Si no hay acarreo, la bola pasa por debajo del marcador y sale, RET NC.
Si hay acarreo, según la coordenada Y de la bola, esta podría borrar el marcador.
ld a, l and $1f cp POINTS_X1_L ret c jr z, reprintPoint_1_print
Cargamos la línea y columna de la posición de la bola en A, LD A, L, nos quedamos con la columna, AND $1F, y lo comparamos con la coordenada X en la que empieza a borrar el marcador del jugador 1 por la izquierda, CP POINTS_X1_L. Si hay acarreo, la bola pasa por la izquierda del marcador y sale, REC C. Si las dos coordenadas coinciden, la bola va a borrar el marcador del jugador 1, y salta para repintarlo, JR Z, reprintPoint_1_print.
Si no hemos salido, ni saltado, seguimos con las comprobaciones:
cp POINTS_X2_R jr z, reprintPoint_2_print ret nc
Comparamos la coordenada X donde está la bola con la coordenada donde se empieza a borrar el marcador del jugador 2 por la derecha, CP POINTS_X2_R. Si son iguales, salta a repintar el marcador del jugador 2, JR Z, reprintPoint_2_print. Si no salta y no hay acarreo, la bola pasa por la derecha y sale, RET NC.
Si no hemos saltado, ni hemos salido, seguimos con las comprobaciones:
reprintPoint_1: cp POINTS_X1_R jr c, reprintPoint_1_print jr nz, reprintPoint_2
Comparamos la coordenada X de la bola con la coordenada donde la bola empieza a borrar el marcador del jugador 1 por la derecha, CP POINTS_X1_R. Si hay acarreo, está borrando el marcador del jugador 1 y salta para repintarlo, JR C, reprintPoint_1_print. Si no son la misma coordenada, pasa por la derecha del marcador del jugador 1 y salta para comprobar si borra el marcador del jugador 2, JR NZ, reprintPoint_2.
Si está borrando el marcador del jugador 1, lo repinta:
reprintPoint_1_print: ld a, (p1points) call GetPointSprite push hl
Cargamos los puntos del jugador 1 en A, LD A, (p1points), obtenemos la dirección del sprite a pintar, CALL GetPointSprite, y preservamos el valor, PUSH HL.
Empezamos pintando el primer dígito, las decenas:
ld e, (hl) inc hl ld d, (hl) ld hl, POINTS_P1 call PrintPoint pop hl
Cargamos en E la parte baja de la dirección de memoria del sprite del primer dígito, LD E, (HL), apuntamos HL a la parte alta de la dirección, INC HL, cargamos la parte alta de la dirección en D, LD D, (HL), cargamos en HL la dirección de memoria dónde se pinta el marcador del jugador 1, LD HL, POINTS_P1, pintamos el primer dígito, CALL PrintPoint, y recuperamos el valor de HL, POP HL.
Terminamos pintando el segundo dígito:
inc hl inc hl ld e, (hl) inc hl ld d, (hl) ld hl, POINTS_P1 inc l jr PrintPoint
Apuntamos HL a la dirección de memoria del sprite del segundo dígito, INC HL, INC HL, cargamos la parte baja de la dirección en E, LD E, (HL), apuntamos HL a la parte alta de la dirección, INC HL, y la cargamos en D, LD D, (HL), cargamos en HL la dirección de memoria dónde se pinta el marcador del jugador 1, LD HL, POINTS_P1, apuntamos HL a la dirección dónde se pinta el segundo dígito, INC L, y pintamos el dígito y salimos, JR PrintPoint.
Posiblemente os estaréis preguntando, ¿Cómo salimos? ¡Si no hay ningún RET!
Estaréis pensando que en lugar de JR PrintPoint, tendríamos que haber puesto:
call PrintPoint ret
Y efectivamente esto funciona, pero no es necesario. Además, de la forma que lo hemos implementado, ahorramos tiempo de proceso y bytes.
La última instrucción de PrintPoint es un RET, y este es el RET que utilizamos para salir, por eso podemos poner JR en lugar de CALL y RET.
Por eso, y porque no tenemos nada que tengamos que recuperar de la pila. Si hubiéramos dejado algo en la pila, los resultados serían impredecibles.
A continuación, podemos ver la diferencia de ciclos de reloj y bytes entre hacerlo de una manera o de otra:
Instrucción | Ciclos de reloj | Bytes |
CALL PrintProint | 17 | 3 |
RET | 10 | 1 |
JR PrintPoint | 12 | 2 |
Nos hemos ahorrado 15 ciclos de reloj y 2 bytes.
También hemos cambiado la forma de repintar. Antes repintábamos los marcadores haciendo OR con lo que hubiera pintado en esa zona, y ahora directamente pintamos el marcador. El resultado es que al pintar el marcador borramos la bola, lo que puede producir algún parpadeo. Como estos parpadeos también existen en el arcade original, lo dejamos así…o podéis cambiarlo.
Vamos ahora a ver como repintamos el marcador del jugador 2:
reprintPoint_2: cp POINTS_X2_L ret c
En este punto, solo hay que comprobar que la bola no esté pasando entre los marcadores sin borrarlos. Comparamos con el límite izquierdo del marcador 2, CP POINTS_X2_L, y si hay acarreo sale pues pasa por la izquierda, RET C.
Si no ha salido, hay que repintar el marcador del jugador 2, lo cual es casi idéntico a lo que hacemos con el marcador del jugador 1, por lo que se marcan las diferencias sin entrar en el detalle:
reprintPoint_2_print: ; # Cambio # ld a, (p2points) ; # Cambio # call GetPointSprite push hl ; 1er dígito ld e, (hl) inc hl ld d, (hl) ld hl, POINTS_P2 ; # Cambio # call PrintPoint pop hl ; 2º dígito inc hl inc hl ld e, (hl) inc hl ld d, (hl) ld hl, POINTS_P2 ; # Cambio # inc l jr PrintPoint
Siendo el aspecto final de la rutina el siguiente:
; – --------------------------------------------------------------------------- ; Repinta el marcador. ; Cada número consta de 1 byte de ancho por 16 de alto. ; Altera el valor de los registros AF, BC, DE y HL. ; – --------------------------------------------------------------------------- ReprintPoints: ld hl, (ballPos) ; Carga la posición de la bola en HL call GetPtrY ; Obtiene tercio, línea y scanline de esta posición cp POINTS_Y_B ; Compara con la posición Y donde ; empieza a borrar el marcador ret nc ; Si no hay acarreo, paso por debajo y sale ; Si llega aquí la bola podría borrar el marcador, según su posición Y ld a, l ; Carga línea y columna de la posición ; de la bola en A and $1f ; Se queda con la columna cp POINTS_X1_L ; Compara con la posición donde la bola borrar el ; marcador del jugador 1 por la izquierda ret c ; Si hay acarreo pasa por la izquierda y sale jr z, reprintPoint_1_print ; Si coinciden, la bola va a borrar el marcador ; y repinta ; Sigue con las comprobaciones cp POINTS_X2_R ; Compara la coordenada X de la bola con la ; posición donde borra el marcador 2 por la derecha jr z, reprintPoint_2_print ; Si son iguales, repinta el marcador ret nc ; Si no hay acarreo, pasa por la derecha y sale ; Resto de comprobaciones para averiguar si borra el marcador 1 reprintPoint_1: cp POINTS_X1_R ; Compara la coordenada X de la bola con la ; posición donde borra el marcador 1 por la derecha jr c, reprintPoint_1_print ; Si hay acarreo, borra el marcador y repinta jr nz, reprintPoint_2 ; Si no es 0 para por la derecha del marcador 1 ; y salta ; Repinta el marcador del jugador 1 reprintPoint_1_print: ld a, (p1points) ; Carga en A la puntuación del jugador 1 call GetPointSprite ; Obtiene la dirección del sprite a pintar push hl ; Preserva el valor de HL ld e, (hl) ; Carga en E la parte baja de la dirección ; del sprite inc hl ; Apunta HL a la parte alta de la dirección ld d, (hl) ; La carga en D ld hl, POINTS_P1 ; Carga en HL la dirección dónde se pinta el ; marcador 1 call PrintPoint ; Pinta el primer dígito pop hl ; Recupera el valor de HL inc hl inc hl ; Apunta HL al sprite del segundo dígito ld e, (hl) ; Carga la parte baja de la dirección en E inc hl ; A punta HL a la parte alta de la dirección ld d, (hl) ; La carga en D ld hl, POINTS_P1 ; Carga en HL la dirección dónde se pinta el ; marcador 1 inc l ; Apunta a la dirección dónde se pinta el segundo ; dígito jr PrintPoint ; Pinta el dígito y sale ; Resto de comprobaciones para averiguar si borra el marcador 2 reprintPoint_2: cp POINTS_X2_L ; Compara la coordenada X de la bola con la ; posición donde borra el marcador 2 por la ; izquierda ret c ; Si hay acarreo, pasa por la izquierda y sale ; Repinta el marcador del jugador 2 reprintPoint_2_print: ld a, (p2points) ; Carga en A la puntuación del jugador 2 call GetPointSprite ; Obtiene la dirección del sprite a pintar push hl ; Preserva el valor de HL ld e, (hl) ; Carga en E la parte baja de la dirección del ; sprite inc hl ; Apunta HL a la parte alta de la dirección ld d, (hl) ; La carga en D ld hl, POINTS_P2 ; Carga en HL la dirección dónde se pinta el ; marcador 2 call PrintPoint ; Pinta el primer dígito pop hl ; Recupera el valor de HL inc hl inc hl ; Apunta HL al sprite del segundo dígito ld e, (hl) ; Carga la parte baja de la dirección en E inc hl ; A punta HL a la parte alta de la dirección ld d, (hl) ; La carga en D ld hl, POINTS_P2 ; Carga en HL la dirección dónde se pinta el ; marcador 2 inc l ; Apunta a la dirección dónde se pinta el segundo ; dígito jr PrintPoint ; Pinta el dígito y sale
Compilamos, cargamos en el emulador, y vemos el resultado.
Podemos ver que la bola ahora va más rápida, incluso cuándo tiene que ir lento. También, si nos fijamos cuando es el jugador 2 el que marca el tanto y la bola debe salir por la derecha, parte de la misma se ve durante un corto espacio de tiempo en la izquierda.
Si hacemos memoria, cuando marcamos un punto la pelota sale desde el campo del jugador que ha ganado el punto. Eso nos lleva a la conclusión de que el problema está en la rutina SetBallRight, y más concretamente, en la primera línea:
ld hl, $4d7f
Según esta línea, posicionamos la pelota en el tercio 1, scanline 5, línea 3, columna 31.
Además, dos líneas más abajo, cambiamos la rotación de la bola, poniéndola a -1:
ld a, $ff ld (ballRotation), a
Ahora, si buscamos el sprite correspondiente a esta rotación, vemos que es el siguiente:
db $00, $78 ; +7/\ 00000000 01111000 -1/$ff
Por lo que la columna 31 la pintamos en blanco, y en la 32 pintamos $78. Pero es que columna 32 no existe: las columnas en total son 32, pero van de la 0 a la 31. Al pintar en la 32, estamos pintando en la columna 0.
Una vez visto esto, la solución es sencilla. Editamos la primera línea de la rutina SetBallRight, para posicionar la bola en la columna 30:
ld hl, $4d7e
Compilamos, cargamos en el emulador, y vemos que este problema ha quedado resuelto.
Y ahora vamos a cambiar la velocidad de la bola, para que no vaya tan rápida.
La configuración de la bola la tenemos guardada en ballSetting, en el archivo Sprite.asm:
; Velocidad y dirección de la bola. ; bits 0 a 3: movimientos de la bola para que cambie la posición Y. ; Valores f = semiplano, 2 = semi diagonal, 1 = diagonal ; bits 4 y 5: velocidad de la bola: 1 muy rápido, 2 rápido, 3 lento ; bit 6: dirección X: 0 derecha / 1 izquierda ; bit 7: dirección Y: 0 arriba / 1 abajo ballSetting: db $31 ; 0011 0001
Según vemos en los comentarios, la velocidad de la bola se configura en los bits 4 y 5. Sería tan sencillo como que la velocidad 2 sea muy rápido, la 3 rápido, y la… En 2 bits solo podemos especificar valores del 0 a 3, y el resto de bits los tenemos ocupados.
Vamos a «robar» un bit a la inclinación de la bola. Como resultado, podremos reducir la velocidad de la bola, y como contraprestación, cuando la bola vaya plana, va a ir un poco más inclinada:
; Velocidad y dirección de la bola. ; bits 0 a 2: Movimientos de la bola para que cambie la posición Y. ; Valores 7 = semiplano, 2 = semi diagonal, 1 = diagonal ; bits 3 y 5: velocidad de la bola: 2 muy rápido, 3 rápido, 4 lento ; bit 6: dirección X: 0 derecha / 1 izquierda ; bit 7: dirección Y: 0 arriba / 1 abajo ballSetting: db $21 ; 0010 0001
Y ahora hay tres rutinas que tenemos que cambiar:
- CheckCrossY en Game.asm: en esta rutina asignamos inclinación y velocidad de la bola, dependiendo de en qué parte de la pala golpea.
- MoveBallY en Game.asm: en esta rutina evaluamos si los movimientos acumulados de la bola han alcanzado los necesarios para cambiar la coordenada Y de la misma.
- SetBallLeft y SetBallRight en Game.asm: en estas rutinas reiniciamos la configuración de la bola.
- Loop en Main.asm: al inicio de esta rutina, verificamos si se ha llegado al número de iteraciones del bucle, necesarias para mover la bola.
Empezamos por CheckCrossY en Game.asm. Localizamos la etiqueta checkCrossY_1_5, y después la línea OR $31:
or $31 ; Hacia arriba, velocidad 3 e inclinación diagonal
Según la nueva definición, vamos a poner velocidad 4 e inclinación diagonal:
00 100 001
Los bits 3 a 5 (100) especifican la velocidad, y los bits 0 a 2 (001) la inclinación. La línea OR $31 debe quedar de la siguiente manera:
or $21
Localizamos la etiqueta checkCrossY_2_5 y ponemos velocidad 3, inclinación semi diagonal:
00 011 010
Modificamos la línea:
or $22 ; Hacia arriba, velocidad 2 e inclinación semi diagonal
Y la dejamos como:
or $1a
Localizamos la etiqueta checkCrossY_3_5 y ponemos velocidad 2, inclinación semi plana:
00 010 111
Modificamos la línea:
or $1f ; Hacia arriba/abajo, velocidad 1 e inclinación semi plana
Y la dejamos como:
or $17
Localizamos la etiqueta checkCrossY_4_5 y ponemos velocidad 3, inclinación semi diagonal:
10 011 010
Modificamos la línea:
or $a2 ; Hacia abajo, velocidad 2 e inclinación semi diagonal
Y la dejamos como:
or $9a
Localizamos la etiqueta checkCrossY_5_5 y ponemos velocidad 4, inclinación diagonal:
10 100 001
Modificamos la línea:
or $b1 ; Hacia abajo, velocidad 3 e inclinación diagonal
Y la dejamos como:
or $a1
Con esto hemos acabado con la parte más laboriosa de la modificación.
Localizamos la etiqueta MoveBallY, y modificamos la segunda línea:
and $0f
Y la dejamos como:
and $07
Con $0f ahora cogeríamos la inclinación y el primer bit de la velocidad. Con $07 solo cogemos la inclinación.
Modificamos el reinicio de la configuración de la bola en las rutinas SetBallLeft y SetBallRight.
En SetBallLeft modificamos la línea:
or $31 ; Pone dirección X a derecha, velocidad 3, ; inclinación diagonal
Y la dejamos como:
or $21
En SetBallRight modificamos la línea:
or $71 ; Pone dirección X a izquierda, velocidad 3, ; inclinación diagonal
Y la dejamos como:
or $61
Vamos a terminar modificando el código de la etiqueta Loop de Main.asm.
A partir de la segunda línea, nos encontramos 4 instrucciones RRCA. Quitamos una, para rotar solo 3 veces y dejar en los bits 0, 1 y 2, la velocidad de la bola.
;rrca ; # Eliminar línea # rrca rrca rrca
Como ahora tenemos 3 bits para la velocidad, en lugar de dos, modificamos la línea siguiente, que es:
and $03
Y la dejamos como:
and $07
Compilamos, cargamos en el emulador, y comprobamos que la velocidad de la bola es ahora más llevadera, en detrimento de la inclinación.
Optimizamos ScanKeys
Vamos a optimizar la rutina ScanKeys, tal y como anunciamos en Ensamblador para ZX Spectrum – Pong: $04 Teclas de control.
En la rutina ScanKeys hay cuatro instrucciones BIT, dos BIT $00, A, y otras dos BIT $01, A. Con estas instrucciones BIT comprobamos el estado de un bit en concreto de un registro, sin alterar el valor de dicho registro; cada instrucción BIT ocupa 2 bytes y tarda 8 ciclos de reloj.
Vamos a sustituir las instrucciones BIT por AND, ahorrándonos un ciclo de reloj en cada una. Sustituimos las instrucciones BIT $00, A por AND $01, y las instrucciones BIT $01, A, por AND $02. Con esta modificación vamos a ahorrar 4 ciclos de reloj, aunque vamos a alterar el valor del registro A que, en este caso, no importa.
Optimizamos Cls
Vamos a optimizar la rutina Cls, tal y como anunciamos en Ensamblador para ZX Spectrum – Pong: $05 Palas y línea central.
Vamos a recordar como es la rutina actualmente:
; – --------------------------------------------------------------------------- ; Limpia la pantalla, tinta 7, fondo 0. ; Altera el valor de los registros AF, BC, DE y HL. ; – --------------------------------------------------------------------------- Cls: ; Limpia los píxeles de la pantalla ld hl, $4000 ; Carga en HL el inicio de la VideoRAM ld (hl), $00 ; Limpia los píxeles de esa dirección ld de, $4001 ; Carga en DE la siguiente posición de la VideoRAM ld bc, $17ff ; 6143 repeticiones ldir ; Limpia todos los píxeles de la VideoRAM ; Pone la tinta en blanco y el fondo en negro ld hl, $5800 ; Carga en HL el inicio del área de atributos ld (hl), $07 ; Lo pone con la tinta en blanco y el fondo en negro ld de, $5801 ; Carga en DE la siguiente posición del área de atributos ld bc, $2ff ; 767 repeticiones ldir ; Asigna el valor a toda el área de atributos ret
La primera parte de la rutina limpia los píxeles, y la segunda asigna los colores a la pantalla. Es en esta segunda parte donde vamos a realizar la optimización.
Una vez ejecutado el primer LDIR, HL vale $57FF y DE vale $5800. Cargar un valor de 16 bits en un registro de 16 bits consume 10 ciclos de reloj y 3 bytes, por lo que haciendo LD HL, $5800 y LD DE, $5801, consumimos 20 ciclos de reloj y 6 bytes.
Como podemos ver, HL y DE valen uno menos de lo que necesitamos para asignar los atributos a la pantalla, por lo que lo único que necesitamos es incrementar su valor en uno, y es ahí donde vamos a conseguir la optimización; vamos a sustituir LD HL, $5800 y LD HL, $5801 por INC HL e INC DE. Incrementar un registro de 16 bits consume 6 ciclos de reloj y ocupa un byte, por lo que el coste total será de 12 ciclos de reloj y 2 bytes, frente a los 20 ciclos de reloj y 6 bytes actuales, logrando un ahorro de 8 ciclos de reloj y 4 bytes.
El aspecto final de la rutina es:
; – --------------------------------------------------------------------------- ; Limpia la pantalla, tinta 7, fondo 0. ; Altera el valor de los registros AF, BC, DE y HL. ; – --------------------------------------------------------------------------- Cls: ; Limpia los píxeles de la pantalla ld hl, $4000 ; Carga en HL el inicio de la VideoRAM ld (hl), $00 ; Limpia los píxeles de esa dirección ld de, $4001 ; Carga en DE la siguiente posición de la VideoRAM ld bc, $17ff ; 6143 repeticiones ldir ; Limpia todos los píxeles de la VideoRAM ; Pone la tinta en blanco y el fondo en negro inc hl ; Apunta HL al inicio del área de atributos ld (hl), $07 ; Lo pone con la tinta en blanco y el fondo en negro inc de ; Apunta DE a la siguiente posición del área de atributos ld bc, $2ff ; 767 repeticiones ldir ; Asigna el valor a toda el área de atributos ret
Optimizamos MoveBall
Vamos a optimizar la rutina MoveBall, tal y como anunciamos en Ensamblador para ZX Spectrum – Pong: $07 Movemos la bola por la pantalla.
Comentamos que se podían ahorrar 5 bytes y 2 ciclos de reloj, lo cual vamos a conseguir modificando 5 líneas del conjunto de rutinas MoveBall, que se encuentran en el archivo Game.asm. En concreto vamos a sustituir las 5 líneas JR moveBall_end por RET; JR ocupa 2 bytes y tarda 12 ciclos de reloj, mientras que RET ocupa 1 byte y tarda 10 ciclos de reloj.
Como podemos observar, en la etiqueta MoveBall_end solo hay una instrucción, RET, de ahí que podamos sustituir todos los JR moveBall_end por RET.
Hemos dicho que solo ahorramos 2 ciclos de reloj, lo cual es debido a que cada vez que se llama a MoveBall, solo se ejecuta uno de los JR, por eso solo se ahorran 2 ciclos y no 10, aunque sí se ahorran 5 bytes.
Los JR que vamos a sustituir, los encontramos como última línea de las etiquetas:
- moveBall_right.
- moveBall_rightLast.
- moveBall_rightChg.
- moveBall_left.
- moveBall_leftLast.
La etiqueta moveBall_end se puede eliminar, pero no el RET que la sigue, aunque la etiqueta en si no ocupa nada.
Optimizamos ReprintLine
Vamos a optimizar la rutina ReprintLine, tal y como anunciamos en Ensamblador para ZX Spectrum – Pong: $08 Campo, palas, bola y temporización.
Comentamos que se podían ahorrar 5 bytes y 22 ciclos de reloj, lo cual vamos a conseguir modificando 8 líneas de la rutina ReprintLine del archivo Video.asm.
Lo primero que vamos a hacer es localizar la etiqueta reprintLine_loopCont y la vamos a mover tres líneas más abajo, justo encima de CALL NextScan.
El siguiente paso es localizar la línea LD C, LINE y borrar las tres líneas siguientes:
jr ReprintLine_loopCont ReprintLine_00: ld c, ZERO
El siguiente paso es localizar las líneas JR C, reprintLine_00 y JR Z, reprintLine_00 y sustituimos reprintLine_00 por reprintLine_loopCont.
El último paso nos lleva al primero. Localizamos la nueva ubicación de la etiqueta reprintLine_loopCont, y 4 líneas más arriba eliminamos LD C, LINE. Dos líneas más abajo de la línea eliminada, sustituimos OR C por OR LINE.
¿Qué hemos hecho?
El objetivo final de la rutina es repintar la parte de la línea central que se ha borrado, sin borrar la parte de la bola que hay donde se tiene que repintar, para lo cual obtenemos los píxeles que hay en pantalla y los mezclamos con la parte de la línea que hay que repintar, y ahí está la cuestión; si lo que hay que repintar de la línea es la parte que va a ZERO (blanco), no es necesario repintarla.
El aspecto final de la rutina es el siguiente:
; – --------------------------------------------------------------------------- ; Repinta la línea central. ; Altera el valor de los registros AF, B y HL. ; – --------------------------------------------------------------------------- ReprintLine: ld hl, (ballPos) ; Carga en HL la posición de la bola ld a, l ; Carga la línea y columna en A and $e0 ; Se queda con la línea or $10 ; Pone la columna a 16 ($10) ld l, a ; Carga el valor en L. HL = Posición inicial ld b, $06 ; Se repintan 6 scanlines reprintLine_loop: ld a, h ; Carga tercio y scanline en A and $07 ; Se queda con el scanline ; Si está en los scanlines 0 o 7 pinta ZERO ; Si está en los scanlines 1, 2, 3, 4, 5 o 6 pinta LINE cp $01 ; Comprueba si está en scanline 1 o superior jr c, reprintLine_loopCont ; Si está por debajo, salta cp $07 ; Comprueba si está en scanline 7 jr z, reprintLine_loopCont ; Si es así, salta ld a, (hl) ; Obtiene los pixeles de la posición actual or LINE ; Los mezcla con C ld (hl), a ; Pinta el resultado en la posición actual reprintLine_loopCont: call NextScan ; Obtiene el scanline siguiente djnz reprintLine_loop ; Hasta que B = 0 ret
Optimizamos PrintPoints y ReprintPoints
Vamos a optimizar la rutina PrintPoints, tal y como anunciamos en Ensamblador para ZX Spectrum – Pong: $0A Partida a dos jugadores y cambio de velocidad de la bola.
Comentamos que podríamos ahorrar 2 bytes y 12 ciclos de reloj haciendo una pequeña modificación en la rutina PrintPoints. Pues bien, estamos de enhorabuena ya que en realidad nos vamos a ahorrar 4 bytes y 24 ciclos de reloj; lo cambios que vamos a realizar en PrintPoints, los vamos a realizar también en ReprintPoints.
En la tercera línea de PrintPoints encontramos PUSH HL, y esta es la primera línea que vamos a cambiar de lugar, ya que preservamos el valor del registro HL antes de tiempo. Cortamos esta línea y la pegamos tres líneas más abajo, justo antes de cargar la dirección de memoria donde se pintan los puntos del jugador 1 en HL, LD HL, POINTS_P1. El motivo de preservar el valor del registro HL es justamente esta instrucción.
Una vez que llamamos a pintar el punto, recuperamos el valor de HL, POP HL, e incrementamos HL dos veces para apuntarlo a la parte baja de la dirección donde está el segundo dígito. Pues bien, como hemos preservado HL después de posicionarnos en la parte alta de la dirección del primer dígito, ahora vamos a quitar uno de estos dos INC HL: nos acabamos de ahorrar 1 byte y 6 ciclos de reloj.
Esta misma modificación tenemos que hacerla al pintar el marcador del jugador 2 y en la rutina ReprintPoints. En total ahorramos 4 bytes y 24 ciclos de reloj.
El aspecto final de PrintPoints es el siguiente:
; – --------------------------------------------------------------------------- ; Pinta el marcador. ; Cada número consta de 1 byte de ancho por 16 de alto. ; Altera el valor de los registros AF, BC, DE y HL. ; – --------------------------------------------------------------------------- PrintPoints: ld a, (p1points) ; Carga en A los puntos del jugador 1 call GetPointSprite ; Obtiene el sprite a pintar en el marcador ; 1er dígito del jugador 1 ld e, (hl) ; Carga en E la parte baja de la dirección ; donde está el primer dígito inc hl ; Apunta HL a la parte alta de la dirección ; donde está el primer dígito ld d, (hl) ; y la carga en D push hl ; Preserva el valor de HL ld hl, POINTS_P1 ; Carga en HL la dirección de memoria donde se pintan ; los puntos del jugador 1 call PrintPoint ; Pinta el primer dígito del marcador del jugador 1 pop hl ; Recupera el valor de HL ; 2º dígito del jugador 1 inc hl ; Apunta HL a la parte baja de la dirección ; donde está el segundo dígito ld e, (hl) ; y la carga en E inc hl ; Apunta HL a la parte alta de la dirección ; donde está el segundo dígito ld d, (hl) ; y la carga en D ld hl, POINTS_P1 ; Carga en HL la dirección de memoria donde se pintan ; los puntos del jugador 1 inc l ; Apunta HL a la dirección donde se pinta el segundo dígito call PrintPoint ; Pinta el segundo dígito del marcador del jugador 1 ld a, (p2points) ; Carga en A los puntos del jugador 2 call GetPointSprite ; Obtiene el sprite a pintar en el marcador ; 1er dígito del jugador 2 ld e, (hl) ; Carga en E la parte baja de la dirección ; donde está el primer dígito inc hl ; Apunta HL a la parte alta de la dirección ; donde está el primer dígito ld d, (hl) ; y la carga en D push hl ; Preserva el valor de HL ld hl, POINTS_P2 ; Carga en HL la dirección de memoria donde se pintan ; los puntos del jugador 2 call PrintPoint ; Pinta el primer dígito del marcador del jugador 2 pop hl ; Recupera el valor de HL ; 2º dígito del jugador 2 inc hl ; Apunta HL a la parte baja de la dirección ; donde está el segundo dígito ld e, (hl) ; y la carga en E inc hl ; Apunta HL a la parte alta de la dirección ; donde está el segundo dígito ld d, (hl) ; y la carga en D ld hl, POINTS_P2 ; Carga en HL la dirección de memoria donde se pintan ; los puntos del jugador 2 inc l ; Apunta HL a la dirección donde se pinta el segundo dígito ; Pinta el segundo dígito del marcador del jugador 2 PrintPoint: ld b, $10 ; Cada dígito son 2 bytes por 16 (scanlines) push de ; Preserva el valor de DE push hl ; Preserva el valor de HL printPoint_printLoop: ld a, (de) ; Carga en A el byte a pintar ld (hl), a ; Pinta el byte inc de ; Apunta DE al siguiente byte call NextScan ; Apunta HL al siguiente scanline djnz printPoint_printLoop ; Hasta que B = 0 pop hl ; Recupera el valor de HL pop de ; Recupera el valor de DE ret
Y el de ReprintPoints:
; – --------------------------------------------------------------------------- ; Repinta el marcador. ; Cada número consta de 1 byte de ancho por 16 de alto. ; Altera el valor de los registros AF, BC, DE y HL. ; – --------------------------------------------------------------------------- ReprintPoints: ld hl, (ballPos) ; Carga la posición de la bola en HL call GetPtrY ; Obtiene tercio, línea y scanline de esta posición cp POINTS_Y_B ; Compara con la posición Y donde empieza a borrar el marcador ret nc ; Si no hay acarreo, paso por debajo y sale ; Si llega aquí la bola podría borrar el marcador, según su posición Y ld a, l ; Carga línea y columna de la posición de la bola en A and $1f ; Se queda con la columna cp POINTS_X1_L ; Compara con la posición donde la bola borrar el marcador ; del jugador 1 por la izquierda ret c ; Si hay acarreo pasa por la izquierda y sale jr z, reprintPoint_1_print ; Si coinciden, la bola va a borrar el marcador y repinta ; Sigue con las comprobaciones cp POINTS_X2_R ; Compara la coordenada X de la bola con la posición donde borra el ; marcador 2 por la derecha jr z, reprintPoint_2_print ; Si son iguales, repinta el marcador ret nc ; Si no hay acarreo, pasa por la derecha y sale ; Resto de comprobaciones para averiguar si borra el marcador 1 reprintPoint_1: cp POINTS_X1_R ; Compara la coordenada X de la bola con la posición donde borra el ; marcador 1 por la derecha jr c, reprintPoint_1_print ; Si hay acarreo, borra el marcador y repinta jr nz, reprintPoint_2 ; Si no es 0 para por la derecha del marcador 1 y salta ; Repinta el marcador del jugador 1 reprintPoint_1_print: ld a, (p1points) ; Carga en A la puntuación del jugador 1 call GetPointSprite ; Obtiene la dirección del sprite a pintar ld e, (hl) ; Carga en E la parte baja de la dirección del sprite inc hl ; Apunta HL a la parte alta de la dirección ld d, (hl) ; La carga en D push hl ; Preserva el valor de HL ld hl, POINTS_P1 ; Carga en HL la dirección dónde se pinta el marcador 1 call PrintPoint ; Pinta el primer dígito pop hl ; Recupera el valor de HL inc hl ; Apunta HL al sprite del segundo dígito ld e, (hl) ; Carga la parte baja de la dirección en E inc hl ; A punta HL a la parte alta de la dirección ld d, (hl) ; La carga en D ld hl, POINTS_P1 ; Carga en HL la dirección dónde se pinta el marcador 1 inc l ; Apunta a la dirección dónde se pinta el segundo dígito jr PrintPoint ; Pinta el dígito y sale ; Resto de comprobaciones para averiguar si borra el marcador 2 reprintPoint_2: cp POINTS_X2_L ; Compara la coordenada X de la bola con la posición donde borra el ; marcador 2 por la izquierda ret c ; Si hay acarreo, pasa por la izquierda y sale ; Repinta el marcador del jugador 2 reprintPoint_2_print: ld a, (p2points) ; Carga en A la puntuación del jugador 2 call GetPointSprite ; Obtiene la dirección del sprite a pintar ld e, (hl) ; Carga en E la parte baja de la dirección del sprite inc hl ; Apunta HL a la parte alta de la dirección ld d, (hl) ; La carga en D push hl ; Preserva el valor de HL ld hl, POINTS_P2 ; Carga en HL la dirección dónde se pinta el marcador 2 call PrintPoint ; Pinta el primer dígito pop hl ; Recupera el valor de HL inc hl ; Apunta HL al sprite del segundo dígito ld e, (hl) ; Carga la parte baja de la dirección en E inc hl ; A punta HL a la parte alta de la dirección ld d, (hl) ; La carga en D ld hl, POINTS_P2 ; Carga en HL la dirección dónde se pinta el marcador 2 inc l ; Apunta a la dirección dónde se pinta el segundo dígito jr PrintPoint ; Pinta el dígito y sale
Sonido
Y abordamos el penúltimo paso: vamos a implementar efectos de sonido cuando la bola golpea con los laterales, las palas, o cuando se marque algún punto.
Añadimos el archivo Sound.asm, y añadimos las constantes y rutinas necesarias para nuestros efectos de sonido, que van a ser los sonidos a reproducir cuando la bola rebota contra los distintos elementos.
Vamos a definir 3 sonidos distintos:
- Cuando se marca un punto.
- Cuando la bola choca con una pala.
- Cuando la bola choca con el borde.
Para cada sonido tenemos que definir la nota y la frecuencia. La frecuencia es el tiempo que va a durar la nota, y la vamos a identificar con el sufijo FQ.
; Punto C_3: EQU $0D07 C_3_FQ: EQU $0082 / $10 ; Pala C_4: EQU $066E C_4_FQ: EQU $0105 / $10 ; Rebote C_5: EQU $0326 C_5_FQ: EQU $020B / $10
Todos los sonidos que vamos a usar son DO, aunque en distintas escalas. A mayor escala, el sonido es más agudo.
Las frecuencias especificadas son las que hacen que la nota dure un segundo, es por eso que las dividimos por 16. Si las multiplicáramos por 2, la nota duraría 2 segundos.
A cada nota, en cada escala, le corresponde una frecuencia propia. En la sección de ficheros os podéis descargar sendas tablas con frecuencias y notas, en decimal, hexadecimal, y código ensamblador.
La siguiente constante que vamos a ver, es la dirección de memoria donde está alojada la rutina BEEPER de la ROM:
BEEPER: EQU $03B5
Esta rutina recibe en HL la nota y en DE la duración, y altera el valor de los registros AF, BC, DE, HL e IX, además de otro aspecto que veremos más adelante.
Debido a que la rutina BEEPER de la ROM altera tantos registros, es recomendable no llamarla directamente; vamos a implementar una rutina que lo haga.
La rutina que vamos a implementar, recibe en A el tipo de sonido a emitir, 1 = punto, 2 = pala, 3 = borde, y no altera el valor de ningún registro:
PlaySound: push de push hl
Preservamos el valor de los registros DE, PUSH DE, y HL, PUSH HL.
cp $01 jr z, playSound_point
Comprobamos si el sonido a reproducir es de tipo 1 (punto), CP $01, y de ser así saltamos, JR Z, playSound_point.
cp $02 jr z, playSound_paddle
Si el sonido no es de tipo 1, comprobamos si es de tipo 2 (pala), CP $02, y de ser así saltamos, JR Z, playSound_paddle.
Si el sonido no es de tipo 1, ni de tipo 2, es de tipo 3 (borde):
ld hl, C_5 ld de, C_5_FQ jr beep
Cargamos en HL la nota, LD HL, C_5, cargamos en DE la frecuencia (duración), LD DE, C_5_FQ, y saltamos a reproducir el sonido, JR beep.
Si el sonido es de tipo 1 o 2, hacemos los mismo, pero con los valores de cada sonido:
playSound_point: ld hl, C_3 ld de, C_3_FQ jr beep playSound_paddle: ld hl, C_4 ld de, C_4_FQ
Nos ahorramos el último JR, ya que justo después viene la rutina que reproduce el sonido:
beep: push af push bc push ix call BEEPER pop ix pop bc pop af pop hl pop de ret
Preservamos los valores de AF, PUSH AF, de BC, PUSH BC, y de IX, PUSH IX. Llamamos a la rutina de la ROM, CALL BEEPER, y recuperamos los valores de IX, POP IX, de BC, POP BC, de AF, POP AF, de HL, POP HL, y de DE, POP DE. Los valores de HL y DE los preservamos al principio de la rutina PlaySound. Por último, salimos, RET.
El aspecto final del archivo Sound.asm, es el siguiente:
; – --------------------------------------------------------------------------- ; Sound ; Fichero con los sonidos ; – --------------------------------------------------------------------------- ; Punto C_3: EQU $0D07 C_3_FQ: EQU $0082 / $10 ; Pala C_4: EQU $066E C_4_FQ: EQU $0105 / $10 ; Rebote C_5: EQU $0326 C_5_FQ: EQU $020B / $10 ; – --------------------------------------------------------------------------- ; Rutina beeper de la ROM. ; ; Entrada: HL -> Nota. ; DE -> Duración. ; ; Altera el valor de los registros AF, BC, DE, HL e IX. ; – --------------------------------------------------------------------------- BEEPER: EQU $03B5 ; – --------------------------------------------------------------------------- ; Reproduce el sonido de los rebotes. ; Entrada: A -> Tipo de rebote. 1. Punto ; 2. Pala ; 3. Borde ; – --------------------------------------------------------------------------- PlaySound: ; Preserva el valor de los registros push de push hl cp $01 ; Evalúa si se emite el sonido de Punto jr z, playSound_point ; Si es así salta cp $02 ; Evalúa si se emite el sonido de Pala jr z, playSound_paddle ; Si es así salta ; Se emite el sonido de Borde ld hl, C_5 ; Carga en HL la nota ld de, C_5_FQ ; Carga en DE la duración (frecuencia) jr beep ; Salta a emitir el sonido ; Se emite el sonido de Punto playSound_point: ld hl, C_3 ; Carga en HL la nota ld de, C_3_FQ ; Carga en DE la duración (frecuencia) jr beep ; Salta a emitir el sonido ; Se emite el sonido de Pala playSound_paddle: ld hl, C_4 ; Carga en HL la nota ld de, C_4_FQ ; Carga en DE la duración (frecuencia) ; Hace sonar la nota beep: ; Preserva el valor de los registros ya que la rutina BEEPER de la ROM los altera push af push bc push ix call BEEPER ; Llama a la rutina BEEPER de la ROM ; Recupera el valor de los registros pop ix pop bc pop af pop hl pop de ret
Para acabar, tenemos que llamar a nuestra nueva rutina para emitir los sonidos de los rebotes de la bola.
Abrimos el archivo Game.asm y localizamos la etiqueta checkBallCross_right. Vamos a añadir dos líneas entre la línea RET NZ, y la línea LD A, (ballSetting):
ld a, $02 call PlaySound
Carga el tipo de sonido (pala) en A, LD A, $02, y emite el sonido, CALL PlaySound.
Localizamos la etiqueta checkBallCross_left. Vamos añadir las mismas dos líneas de antes entre la línea RET NZ, y la línea LD A, (ballSetting):
ld a, $02 call PlaySound
Localizamos la etiqueta moveBall_upChg. Justo debajo de la misma, añadimos dos líneas casi iguales a las anteriores:
ld a, $03 call PlaySound
Localizamos la etiqueta moveBall_downChg. Justo debajo de la misma, añadimos las dos líneas anteriores:
ld a, $03 call PlaySound
Localizamos la etiqueta moveBall_rightChg, y justo debajo añadimos:
ld a, $01 call PlaySound
Cinco líneas más abajo localizamos CALL SetBallLeft. Debajo añadimos:
ld a, $03 call PlaySound
Localizamos la etiqueta moveBall_leftChg, y justo debajo añadimos:
ld a, $01 call PlaySound
Cinco líneas más abajo localizamos CALL SetBallRight. Debajo añadimos:
ld a, $03 call PlaySound
Por último, abrimos el archivo Main.asm, localizamos la rutina Loop y justo encima añadimos las siguientes líneas:
ld a, $03 call PlaySound
Nos vamos al final del fichero, y en la parte de los «includes», incluimos el archivo Sound.asm:
include "Sound.asm"
Si todo ha ido bien, hemos llegado al final. Compilamos, cargamos en el emulador y…

¿Qué le pasa al borde? ¿Por qué es blanco? Bueno, ya advertimos que la rutina BEEPER de la ROM altera muchas cosas, y una de ellas es el color del borde, aunque tiene fácil solución.
Por suerte, tenemos una variable de sistema dónde podemos guardar el color del borde. En esta variable se guardan también los atributos de la pantalla inferior. El fondo de dicha pantalla es el color del borde.
Abrimos el archivo Video.asm y al inicio del mismo declaramos una constante con la dirección de memoria de dicha variable del sistema:
BORDCR: EQU $5c48
Localizamos las rutina Cls, y antes de la línea LD HL, $5800, añadimos:
ld a, $07 ; Fondo negro, tinta blanca
Modificamos la línea LD (HL), $07 dejándola así:
ld (hl), a
Por último, antes de RET, añadimos:
ld (BORDCR), a
Compilamos, cargamos en el emulador, y ahora sí. ¿Hemos terminado nuestro PorompomPong?

Compatibilidad con 16K
Todavía nos falta una última cosa por hacer. ¿Es compatible nuestro programa con el modelo de 16K? Pues todavía no, pero como no trabajamos con interrupciones, es muy sencillo hacer que sea compatible.
Vamos a abrir el archivo Main.asm, vamos a localizar las directivas ORG y END, y vamos a sustituir $8000 por $5dad en el caso de ORG. En el caso de END, vamos a sustituir $8000 por Main, que es la etiqueta de entrada al programa.
Si ahora compilamos y cargamos en el emulador con el modelo de 16K, nuestro programa es compatible.
Si nos fijamos bien, podemos observar que se ha perdido algo de velocidad. Esta perdida es debida a que los segundos 16KB del ZX Spectrum, que es donde ahora cargamos el programa, es lo que se llama memoria contenida, y está compartida con la ULA. Cuando la ULA trabaja, todo se para.
Vamos a volver a cambiar la velocidad a la que va la bola.
Abrimos el archivo Sprite.asm, localizamos la etiqueta ballSetting, comentamos la línea OR $21 y escribimos justo debajo:
; or $21 or $19
Ahora la bola se inicia a velocidad 3, que va a ser la más lenta.
Abrimos el archivo Game.asm, localizamos la etiqueta SetBallLeft, comentamos la línea 7 y escribimos justo debajo:
; or $21 or $19
Ahora, cuando reiniciamos la bola para que salga por la izquierda de la pantalla, se inicia a velocidad 3.
Localizamos la etiqueta SetBallRight, comentamos la línea 7, y escribimos justo debajo:
; or $21 or $19
Ahora, cuando reiniciamos la bola para que salga por la derecha de la pantalla, se inicia a velocidad 3.
Localizamos la etiqueta checkCrossY_1_5, comentamos la línea 7, y escribimos justo debajo:
; or $21 or $19
Ahora la velocidad de la bola es 3 en lugar de 4.
Localizamos la etiqueta checkCrossY_2_5, comentamos la línea 7, y escribimos justo debajo:
; or $1a or $12
Ahora la velocidad de la bola es 2 en lugar de 3.
Localizamos la etiqueta checkCrossY_3_5, comentamos la línea 7, y escribimos justo debajo:
; or $17 or $0f
Ahora la velocidad de la bola es 1 en lugar de 2.
Localizamos la etiqueta checkCrossY_4_5, comentamos la línea 7, y escribimos justo debajo:
; or $9a or $92
Ahora la velocidad de la bola es 2 en lugar de 3.
Localizamos la etiqueta checkCrossY_5_5, comentamos la línea 3, y escribimos justo debajo:
; or $a1 or $99
Ahora la velocidad de la bola es 3 en lugar de 4.
Compilamos, probamos en el emulador y… ¡Hemos terminado!
Frecuencias y notas
En el fichero «Frecuencias y Notas», se muestran los valores para las frecuencias y las notas de 8 escalas distintas. Cuanto menor es la escala, más grave, y cuanto mayor, más aguda.
Los valores de las frecuencias se han obtenido de Dilwyn Jones Sinclair QL Pages.
Estas frecuencias indican la duración de la nota, que en estos casos son de un segundo.
Para calcular cada nota, la fórmula es la siguiente:
(437500/frecuencia)-30125
Esta fórmula ha sido tomada del libro Programación Avanzada del ZX Spectrum de Steve Kramer. Podéis encontrar dicha fórmula en la página 18.
Enlaces de interés
- Notepad++.
- Visual Studio Code.
- Sublime Text.
- ZEsarUX.
- PASMO.
- Git.
- Curso de ensamblador Z80 de Compiler Software.
- Z80 instruction set.