ZX Spectrum screen
Screen layout
In this chapter we are going to talk about the screen layout of the ZX Spectrum.
Translated with DeepL.
Table of contents
- Screen layout
- ZX Spectrum graphic area
- ZX Spectrum attributes area
- Routines
- Memory address from character coordinates
- Character coordinate from memory address
- Next row memory address
- Previous row memory address
- Next scanline
- Previous Scanline
- Attribute address from character coordinates
- Character coordinates from attribute address
- Attribute address from graphic area address
- Graphic area address from attribute address
- Conclusion
ZX Spectrum graphic area
The screen of the ZX Spectrum has a resolution of 256*192 pixels, or what is the same 32 columns and 192 scanlines. With each byte we can configure 8 pixels, those at one will have the color of the ink (INK) and those at zero the color of the background (PAPER); 256 pixels / 8 pixels per byte = 32 columns. The graphic area occupies 6,144 bytes ($1800) and starts at memory address 16,384 ($4000).
One of the peculiarities of the ZX Spectrum is the way the graphic area is distributed. Each character consists of 64 pixels, or 8×8 pixels. Each line of the character is called a scanline. If we paint on the first scanline of a character and then add 32 to the memory position, the logic tells us that we are on the next scanline of the character, but it is not so because we are actually on the first scanline of the character just below the one we are on.
If we paint position $4000, the first byte of the screen, and then we want to paint in the first scanline of row 8 of the screen, the logic says that we would only have to add 8 times 32 ($20) to position $4000, so the first scanline of the first character of row 8 would be in position $4100. But this is not the case since position $4100 corresponds to the second scanline of the first character on the screen.
This behavior is due to the fact that the ZX Spectrum screen is divided into thirds. Each of the thirds has 8 rows and each of the rows has 8 scanlines. The first third goes from memory location $4000 to $47FF, the second third from $4800 to $4FFF and the third third third from $5000 to $57FF.
Although this may seem strange at first, when you see the composition that the bytes take when making up a memory location, it all makes sense. The composition of the bytes is as follows:
010T TSSS RRRC CCCC
Where 010 is always fixed, TT represents the third (from 0 to 2), SSS represents the scanline within the character (from 0 to 7), RRR represents the row within the third (from 0 to 7) and CCCCCC represents the column (from 0 to 31).
If we look at the row, from 0 to 23, bits 0, 1 and 3 always indicate the row within the third, and bits 4 and 5 the third, so TTRRR is equivalent to the line in character coordinates (from 0 to 23).
Row 0: 0000 0000 Row 0 of third 0
Row 7: 0000 0111 Row 7 of third 0
Row 8: 0000 1000 Row 0 of third 1
Row 15: 0000 1111 Row 7 of third 1
Row 16: 0001 0000 Row 0 of third 2
Row 23: 0001 0111 Row 7 of third 2
To see all of the above, let’s perform a simple example that fills the bytes of the first column with a simple $AA pattern (1001 1001).
org $8000
ld hl, $4000 ; HL = VideoRAM start address
ld bc, $20 ; BC = value to be added to move to the next character
ld a, $08 ; A = to draw the first scanline of the first third of the first third
loop:
ld (hl), $aa ; Load on screen the pattern 10011001
add hl, bc ; Advances HL to the next row
dec a ; Decrease A
jr nz, loop ; Loop until A reaches 0
ret ; Back to Basic
end $8000
If we run the program, it draws the pattern $AA (10101010) in the first scanline of the first 8 rows.
To see the ZX Spectrum screen layout, just change the LD A line, $08 and load multiples of $08; $08, $10, $18, $20, $28, $30, $38, $40, $40, $48….
Until we reach $48, we will not see how the first scanline of the 8 rows of the second third is painted.
ZX Spectrum attributes area
Following the graphic area (pixels) is the attribute area, which runs from address 22,528 ($5800) to address 23,295 ($5AFF), with a length of 768 ($300) bytes.
Each byte in the attribute area sets the attributes of a character (8×8 pixels), which is the reason for the famous attribute clash.
The format of each byte of the attribute area is as follows:
FBPPPIII
Where F is FLASH, B = BRIGHT, P = PAPER and I = INK.
In the case of the attributes area the distribution is linear, so if we are in the byte of the attributes of column 0, row 0 of the screen and add 32 we are in the attributes of column 0, row 1.
The composition of the memory addresses of the attribute area is as follows:
0101 10TT RRRC CCCC
Where 0101 10 is always fixed, TT represents the third (from 0 to 2), RRR represents the row within the third (from 0 to 7) and CCCCC represents the column (from 0 to 31). Again TTRRR is equivalent to the row in character coordinates (from 0 to 23).
Routines
From here, we are going to see routines that can help you when working with the ZX Spectrum screen. Keep in mind that these routines do not control if the coordinates are outside the screen area.
Memory address from character coordinates
The CharCoord2PointerHR routine takes character coordinates and returns the corresponding memory address.
; ----------------------------------------------------------------
; CharCoord2PointerHR: Gets the memory address
; corresponding to the specified character coordinates.
;
; Entrada: B -> Y-coordinate (0 a 23).
; C -> X-coordinate (0 a 31).
;
; Salida: HL -> Memory address corresponding to the
; coordinates (010T TSSS RRRC CCCC).
;
; Alters the value of AF and HL registers.
; -----------------------------------------------------------------
CharCoord2PointerHR:
ld a, b ; A = row. Bits 0, 1 and 2 row. Bits 3 and 4 third.
and $18 ; It stays with the third, 0001 1000.
; Scanline always 0.
or $40 ; Add fixed part, 0100 0000.
ld h, a ; H = A. High part of calculated direction.
ld a, b ; A = row.
and $07 ; It stays with the row inside the third.
rrca
rrca
rrca ; Rotate three times to pass value to bits 5, 6 and 7.Rotate three times to pass value to bits 5, 6 and 7.
or c ; Add column.
ld l, a ; L = A. Lower part of calculated direction.
ret
To test this routine we can pass it positions and then load a pattern at the returned address to see where it draws.
org $8000
ld b, $10 ; Load in B the row.
ld c, $10 ; Load in C the column.
call CharCoord2PointerHR ; Calculates memory location.
ld (hl), $ff ; Activates all pixels in the
; memory location.
inc b ; Increases row.
inc c ; Increases column.
call CharCoord2PointerHR ; Calculates memory location.
ld (hl), $ff ; Activates all pixels in the
; memory location.
inc b ; Increases row.
dec c ; Increases column.
call CharCoord2PointerHR ; Calculates memory location.
ld (hl), $ff ; Activates all pixels in the
; memory location.
ret
end $8000
Character coordinate from memory address
The PointerHR2CharCoord routine takes a memory address and returns the corresponding character coordinates.
; ----------------------------------------------------------------
; PointerHR2CharCoord: Obtiene las coordenadas de carácter
; correspondientes a la dirección de memoria especificada.
;
; Entrada: HL -> Memory pointer. 010T TSSS RRRC CCCC.
;
; Salida: B -> Y-coordinate (0 a 23).
; C -> X-coordinate (0 a 31).
;
; Alters the value of AF and BC registers.
; ----------------------------------------------------------------
PointerHR2CharCoord:
ld a, h ; A = upper part of memory address. 010T TSSS.
and $18 ; Keeps the third. Bits 4 and 5 of the Y coordinate.
; Scanline always at 0, they are character coordinates.
ld b, a ; B = A.
ld a, l ; A = lower part of the memory address. RRRC CCCC.
and $e0 ; It stays with the row inside the third.
rrca
rrca
rrca ; Rotate three times to pass value to bits 0, 1 and 3.
or b ; Add bits 4 and 5.
ld b, a ; B = A. Calculated Y-coordinate.
ld a, l ; A = lower part of the memory address. RRRC CCCC.
and $1f ; It keeps the column.
ld c, a ; C = A. Calculated X-coordinate.
ret
Next row memory address
The PointerHRNextLine routine takes a memory address and returns the memory address corresponding to the next row.
; ----------------------------------------------------------------
; PointerHRNextLine: gets the memory address
; corresponding to the next row.
;
; Entrada: HL -> Current address. 010T TSSS RRRC CCCC.
;
; Salida: HL -> Dirección de la fila siguiente.
; 010T TSSS RRRC CCCC.
;
; Alters the value of AF and HL registers.
; ----------------------------------------------------------------
PointerHRNextLine:
ld a, l ; A = lower part of the address. RRRC CCCC.
add a, $20 ; Add one line (RRRC CCCC + 0010 0000).
ld l, a ; L = A.
ret nc ; If there is no carry, the row follows between 0
; and 7, it exits.
; There is carry-over, the third of the screen has to be changed.
ld a, h ; A = upper part of memory address. 010T TSSS.
add a, $08 ; Add one third (010T TSSS + 0000 1000).
ld h, a ; H = A.
ret
Previous row memory address
The PointerHRPreviousLine routine takes a memory address and returns the memory address corresponding to the previous row.
; ----------------------------------------------------------------
; PointerHRPreviousLine: gets the memory address
; corresponding to the previous row.
;
; Entrada: HL -> Current address. 010T TSSS RRRC CCCC.
;
; Salida: HL -> Dirección de la fila anterior.
; 010T TSSS RRRC CCCC.
;
; Alters the value of AF and HL registers.
; ----------------------------------------------------------------
PointerHRPreviousLine:
ld a, l ; A = lower part of the address. RRRC CCCC.
and $e0 ; It keeps the row part (1110 0000).
jr z, PointerHRPreviousLine_continue ; If line is 0,
; change of third.
; No change of third.
ld a, l ; A = lower part of the address, RRRC CCCC.
sub $20 ; Subtract one row (RRRC CCCC - 0010 0000).
ld l, a ; L = A. Calculated address.
ret
PointerHRPreviousLine_continue:
; It is necessary to change the third party.
ld a, l ; A = lower part of the address. RRRC CCCC.
or $e0 ; Set the row to 7 (RRRC CCCC or 1110 0000).
ld l, a ; L = A. Lower part of the calculated address.
ld a, h ; A = upper part of the address. 010T TSSS.
sub $08 ; Subtract one third (010T TSSS - 0000 1000).
ld h, a ; H = A. Upper part of the calculated address.
ret
Next scanline
The PointerHRNextScanLine routine takes a memory address and returns the address of the next scanline.
; ----------------------------------------------------------------
; PointerHRNextScanLine: gets the memory address
; corresponding to the next scanline.
;
; Entrada: HL -> current address. 010T TSSS RRRC CCCC.
;
; Salida: HL -> address of the next scanline.
; 010T TSSS RRRC CCCC.
;
; Alters the value of AF and HL registers.
; ----------------------------------------------------------------
PointerHRNextScanLine:
ld a, h ; A = upper part of the address. 010T TSSS.
and $07 ; Keeps the scanline.
cp $07 ; Check if scanline is 7.
jr z, PointerHRNextScanLine_continue ; Yes, change of line.
; Scanline is not 7.
inc h ; Increases the scanline by 1 and exits.
ret
PointerHRNextScanLine_continue:
; The row must be changed.
ld a, l ; A = lower part of the address. RRRC CCCC.
add a, $20 ; Add one line (RRRC CCCC + 0010 0000).
ld l, a ; L = A.
ld a, h ; A = upper part of the address. 010T TSSS.
jr nc, PointerHRNextScanLine_end ; If there is no carriage, skip
; to finish the calculation.
; There is carriage, it is necessary to change the third party.
add a, $08 ; Add one to the third (010T TSSS + 0000 1000).
PointerHRNextScanLine_end:
and $f8 ; Keeps the fixed part and the third part.
; Set the scanline to 0.
ld h, a ; H = A. Calculated address.
ret
To test the routine, let’s use a small program to see how it draws. Contrary to what we saw in the first example, now we paint a scanline followed by the one right behind it.
org $8000
ld hl, $4000 ; HL = VideoRAM start address.
ld b, $08 ; B = to draw 8 scanlines.
loop:
ld (hl), $aa ; Loads the $AA pattern on screen.
call PointerHRNextScanLine ; Advances to next scanline.
djnz loop ; Loop until B reaches 0.
ret
end $8000
If we run the program, it draws the $AA pattern on the first character. The first example did it on the first scanline of the first 8 rows.
As we did in the first example, we can load multiples of $08 in B to see how the program is drawing; $08, $10, $18, $20, $28, $30, $38, $40, $48….
Previous Scanline
The PointerHRPreviousScanLine routine takes a memory address and returns the memory address corresponding to the previous scanline.
; ----------------------------------------------------------------
; PointerHRPreviousScanLine: gets the memory address
; corresponding to the previous scanline.
;
; Entrada: HL -> current address. 010T TSSS RRRC CCCC.
;
; Salida: HL -> address of previous scanline.
; 010T TSSS RRRC CCCC.
;
; Alters the value of AF and HL registers.
; ----------------------------------------------------------------
PointerHRPreviousScanLine:
ld a, h ; A = upper part of the address. 010T TSSS.
and $07 ; Keeps the scanline.
or a ; Check if it is at 0.
jr z, PointerHRPreviousScanLine_continue ; Yes, change line.
; No change of line.
dec h ; Decrements the scanline and exits.
ret
PointerHRPreviousScanLine_continue:
ld a, l ; A = lower part of the address. RRRC CCCC.
and $e0 ; Keeps the row part.
or a ; Check if it is at 0.
jr z, PointerHRPreviousScanLine_change ; Yes, change third.
; No need to change third.
ld a, l ; A = lower part of the address. RRRC CCCC.
sub $20 ; Subtract one row, (RRRC CCCC - 0010 0000).
ld l, a ; L = A.
jr PointerHRPreviousScanLine_end ; Skip to end of calculation.
PointerHRPreviousScanLine_change:
; Need to change third.
ld a, l ; A = lower part of the address. RRRC CCCC.
or $e0 ; Sets the line to 7.
ld l, a ; L = A.
ld a, h ; A = upper part of the address. 010T TSSS.
sub $08 ; Subtract one to the third.
ld h, a ; H = A.
PointerHRPreviousScanLine_end:
ld a, h ; A = upper part of the address. 010T TSSS.
and $F8 ; Keeps the fixed part and the third part.
or $07 ; Set the scanline to 7.
ld h, a ; H = A.
ret
And in order to test the routine, another simple program.
org $8000
ld hl, $57ff ; HL = VideoRAM end address.
ld b, $08 ; B = to draw 8 scanlines.
loop:
ld (hl), $aa ; Loads the $AA pattern on the screen.
call PointerHRPreviousScanLine ; Back to previous scanline.
djnz loop ; Loop until B reaches 0.
ret
end $8000
Attribute address from character coordinates
The CharCoord2PointerAttr routine takes character coordinates and gets the corresponding attribute memory address.
; ----------------------------------------------------------------
; CharCoord2PointerAttr: takes character coordinates and obtains
; the memory address of the corresponding attribute.
;
; Entrada: B -> Y-coordinate
; C -> X-coordinate
;
; Salida: HL -> Attribute memory address.
; 0101 10TT RRRC CCCC.
;
; Alters the value of AF and HL registers.
; ----------------------------------------------------------------
CharCoord2PointerAttr:
ld a, b ; Load row in A
and $18 ; Keeps the third bits
rrca
rrca
rrca ; Pass them to bits 0 and 1
or $58 ; Adds the fixed bits of the high part of the address
ld h, a ; H = 0101 10TT
ld a, b ; Load row in A
and $07 ; Keeps the bits of the row
rrca
rrca
rrca ; Pass them to bits 5, 6, and 7
or c ; Adds the bits of the column
ld l, a ; L = RRRC CCCC
ret ; HL = 0101 10TT RRRC CCCC (attribute address)
Social networks can be wonderful; NathanielSalis made me see that in this routine we can save bytes and clock cycles by dispensing with three rotations. The code would look like this:
; ----------------------------------------------------------------
; CharCoord2PointerAttr: takes character coordinates and obtains
; the memory address of the corresponding attribute.
;
; Entrada: B -> Y-coordinate
; C -> X-coordinate
;
; Salida: HL -> Attribute memory address.
; 0101 10TT RRRC CCCC.
;
; Alters the value of AF and HL registers.
; ----------------------------------------------------------------
CharCoord2PointerAttr:
ld a, b ; Load row in A
; Before keeping the bits of the third, we carry out the rotations
rrca
rrca ; Passes the bits of the third to bits 0 and 1
rrca ; and those of the row to bits 5, 6 and 7
ld l, a ; Load the result in L
and $03 ; A = bits of the third
or $58 ; Adds the fixed bits of the high part of the address
ld h, a ; H = 0101 10TT
ld a, l ; A = row in bits 5, 6 and 7 and third in bits 0 and 1
and $e0 ; Keeps the bits of the line
or c ; Adds the bits of the column
ld l, a ; L = RRRC CCCC
ret ; HL = 0101 10TT RRRC CCCC (attribute address)
The first version of the routine occupies 18 bytes and takes 75 cycles to execute, while the second occupies 16 bytes and takes 67 cycles to execute, so we save two bytes and 8 cycles.
Character coordinates from attribute address
The PointerAttr2CharCoord routine takes a memory address of an attribute and returns the corresponding character coordinates.
; ----------------------------------------------------------------
; PointerAttr2CharCoord: takes an attribute memory address
; and returns the corresponding character coordinates.
;
; Entrada: HL -> Attribute memory address.
; 0101 10TT RRRC CCCC.
;
; Salida: B -> Y-coordinate
; C -> X-coordinate
;
; Alters the value of AF and HL registers.
; ----------------------------------------------------------------
PointerAttr2CharCoord:
ld a, h ; Loads the upper part of the address in A
and $03 ; Keeps the bits of the third
rlca
rlca
rlca ; Passes them to bits 3 and 4
ld b, a ; B = 000TT000
ld a, l ; Load the lower part of the address in A
and $e0 ; Keeps the bits in the row
rlca
rlca
rlca ; Passes them to bits 0, 1 and 2
or b ; Add the third
ld b, a ; B = TTCCC (Y-coordinate)
ld a, l ; Load the lower part of the address in A
and $1f ; It keeps the bits of the column
ld c, a ; C = 000CCCCC (X-coordinate)
ret ; BC = character coordinates
Attribute address from graphic area address
The PointerHR2PointerAttr routine takes a memory address from the graphic area and returns the corresponding attribute memory address.
; ----------------------------------------------------------------
; PointerHR2PointerAttr: takes a memory address of the graphic area
; and returns the memory address of the corresponding attribute.
;
; Entrada: HL -> Memory address of the graphic area.
; 010T TSSS RRRC CCCC.
;
; Salida: HL -> Attribute memory address.
; 0101 10TT RRRC CCCC.
;
; Alters the value of AF and HL registers.
; ----------------------------------------------------------------
PointerHR2PointerAttr:
ld a, h ; Loads the upper part of the address in A
and $18 ; Keeps the bits of the third
rrca
rrca
rrca ; Passes them to bits 0 and 1
or $58 ; Add the fixed part
ld h, a ; A = 010110TT
ret ; HL = attribute memory address
Graphic area address from attribute address
The PointerAttr2PointerHR routine takes an attribute memory address and returns the memory address of the graphic area corresponding to scanline 0.
; ----------------------------------------------------------------
; PointerAttr2PointerHR: takes a memory address of attribute
; and returns the memory address of the corresponding graphic area
; to the first scanline.
;
; Entrada: HL -> Attribute memory address.
; 0101 10TT RRRC CCCC.
;
; Salida: HL -> Memory address of the graphic area.
; 010T TSSS RRRC CCCC.
;
; Alters the value of AF and HL registers.
; ----------------------------------------------------------------
PointerAttr2PointerHR:
ld a, h ; Loads the upper part of the address in A
and $03 ; Keeps the bits of the third
rlca
rlca
rlca ; Passes them to bits 0 and 1
or $40 ; Add the fixed part
ld h, a ; A = 010TT000
ret ; HL = address of the first scanline
Conclusion
Everything I have put here, I have learned by following the assembler course found at speccy.org and reading the book Código Máquina del ZX-Spectrum by Jesús Alonso Cano.
The routines shown here are the exercises I have done to practice what I have learned.
You can also visit the rest of tutorials:
And remember, if you use it, don’t just copy it, try to understand it and adapt it to your needs.