Optimizing Pixel Plotting on the Speccy: A New Fast Routine
It's pixel plotting time! In this article, we will look at what I believe is the fastest general-purpose pixel plotting routine for the ZX Spectrum (so far). Ultimately, this is another in a series of attempts to improve on the speed of the ZX Spectrum ROM's PLOT implementation. To be clear, Steve Vickers' code is actually quite clever, but he was working under a very different set of engineering constraints.
Note that this article assumes familiarity with Z80 assembly language and the ZX Spectrum's "swizzled" video RAM layout, in which the address bits are arranged as:
0 1 0 y7 y6 y2 y1 y0 y5 y4 y3 x7 x6 x5 x4 x3
where the target pixel is selected within the byte using:
10000000 >> x2 x1 x0
Before diving into the new approach, let's briefly recap the two milestone improvements introduced in the 1990s by respected demo scene programmers. This is important not only for understanding the underlying ideas, but also for establishing reference implementations against which cycle counts can be compared, since different authors made different assumptions.
Let's first look at the "canonical" 73-cycle routine introduced by Busy (Slavomir Labsky) around 1993. The fundamental speed improvement over the ROM implementation is the use of a 1 KiB lookup table containing pre-swizzled bits for the X and Y values along with a pre-rotated pixel mask for each X coordinate:
; Input: D = Y (0..191), E = X (0..255)
7 ld h,TAB ; H = lookup table base
4 ld l,d ; L = Y coordinate
7 ld d,(hl) ; D = 0 1 0 y7 y6 y2 y1 y0
4 inc h
7 ld a,(hl) ; A = y5 y4 y3 0 0 0 0 0
4 inc h
7 ld l,e ; L = X coordinate
7 or (hl) ; A = y5 y4 y3 x7 x6 x5 x4 x3
4 ld e,a ; DE = final video RAM address
4 inc h
7 ld a,(de)
7 or (hl) ; apply pixel mask
7 ld (de),a
Improving on such a tightly optimized piece of code is neither easy nor obvious. However, to the best of my knowledge, two coders — Alone Coder (Dmitriy Bystrov) and DC Pak (Przemyslaw Kasprzak) — independently discovered a clever two-pointer trick around 1998–1999. We will begin with DC Pak's 68-cycle version for clarity:
; Input: L = Y (0..191), E = X (0..255)
7 ld h,TAB
7 ld d,TAB + 2
7 ld a,(de) ; A = 0 0 0 x7 x6 x5 x4 x3
4 inc d
7 add a,(hl) ; A = y5 y4 y3 x7 x6 x5 x4 x3
4 inc h
7 ld h,(hl) ; H = 0 1 0 y7 y6 y2 y1 y0
4 ld l,a ; HL = final video RAM address
7 ld a,(de)
7 or (hl) ; apply pixel mask
7 ld (hl),a
Alone Coder's claimed 58-cycle version, published in ZX-Guide 1, looks more streamlined but is ultimately based on the same idea:
; Input: L = Y (0..191), E = X (0..255), C = TAB, D = TAB + 2
4 ld h,c ; H = TAB
7 ld a,(de) ; A = 0 0 0 x7 x6 x5 x4 x3
4 inc d
7 or (hl) ; A = y5 y4 y3 x7 x6 x5 x4 x3
4 inc h
7 ld h,(hl) ; H = 0 1 0 y7 y6 y2 y1 y0
4 ld l,a ; HL = final video RAM address
7 ld a,(de)
7 or (hl) ; apply pixel mask
7 ld (hl),a
The actual cycle counts, however, are somewhat more subtle. DC Pak's version could still benefit from contextual optimizations, such as replacing the immediate loads of TAB and TAB + 2 with register-based loads. He was no doubt aware of this possibility, but couldn't reasonably assume that the BC register pair would always be freely available.
Alone Coder appears to push the contextual assumptions somewhat further by assuming the availability of register C and treating D as already initialized to TAB + 2. However, since this value must be reinitialized between calls for the routine to function correctly, the setup cost cannot be ignored. Therefore, we must include either a 7-cycle LD D,TAB + 2, or at minimum a 4-cycle instruction like LD D,B or DEC D to restore the table base.
Both authors assume E = X and L = Y. While convenient for the routine's internal flow, this makes fetching coordinates from memory or the stack a bit awkward. As a result, any cycles saved this way may simply be burned elsewhere on register shuffling.
In fact, if the routine were rewritten to use a native register pair as input, it would end up being 3 cycles slower than the Busy's version.
Overall, it's reasonable to conclude that the best-case version of this approach is 62 cycles long and can be stated as follows. This will serve as the reference for further comparison:
; Input: L = Y (0..191), E = X (0..255), B = TAB, C = TAB + 2
4 ld h,b ; H = TAB
4 ld d,c ; D = TAB + 2
7 ld a,(de) ; A = 0 0 0 x7 x6 x5 x4 x3
4 inc d
7 or (hl) ; A = y5 y4 y3 x7 x6 x5 x4 x3
4 inc h
7 ld h,(hl) ; H = 0 1 0 y7 y6 y2 y1 y0
4 ld l,a ; HL = final video RAM address
7 ld a,(de)
7 or (hl) ; apply pixel mask
7 ld (hl),a
At last, we can introduce the new, even faster solution. The key observation is that to combine y5 y4 y3 with x7 x6 x5 x4 x3, we don't need to rely on a bitwise operation. Since the three bits y5 y4 y3 represent eight possible states, they can be used as an index into a set of eight lookup sub-tables. Each such sub-table stores the combined bit pattern y5 y4 y3 x7 x6 x5 x4 x3. This increases the memory footprint from 1024 to 2816 bytes, which is admittedly the biggest drawback of this approach. However, when maximum speed is required, this cost is still manageable.
Let's first consider the "canonical" 65-cycle version prior to introducing contextual optimizations. Although this is nominally slightly slower than the "best-case" routine described above, it can already be faster in real-world use because the convention D = Y, E = X reduces register shuffling:
; Input: D = Y (0..191), E = X (0..255)
7 ld h,TAB
4 ld l,d ; L = Y coordinate
7 ld b,(hl) ; B = 0 1 0 y7 y6 y2 y1 y0
4 inc h
7 ld d,(hl) ; one of 8 sub-tables based on y5 y4 y3
7 ld a,(de) ; A = y5 y4 y3 x7 x6 x5 x4 x3
4 ld c,a ; BC = final video RAM address
4 inc h
7 ld a,(bc)
7 or (hl) ; apply pixel mask
7 ld (bc),a
Notably, if we make similar contextual assumptions to those in the "best-case" Alone Coder / DC Pak routine, the cycle count drops to just 58:
; Input: L = Y (0..191), E = X (0..255), B = TAB, C = TAB + 2
4 ld h,b ; H = TAB
7 ld d,(hl) ; D = 0 1 0 y7 y6 y2 y1 y0
4 inc h
7 ld h,(hl) ; one of 8 sub-tables based on y5 y4 y3
4 ld l,e ; L = X coordinate
7 ld e,(hl) ; E = y5 y4 y3 x7 x6 x5 x4 x3
4 ld h,c ; H = TAB + 2
7 ld a,(de)
7 or (hl) ; apply pixel mask
7 ld (de),a
For convenience, I provide a lookup table generator so you can start experimenting with the routine yourself:
TAB equ #F5
ld b,%11111000
ld l,0
GenY ld h,TAB
ld a,l
and b
rra
scf
rra
rra
ld c,a
xor l
and b
xor l
ld (hl),a
inc h
ld a,c
and 7
add a,TAB + 3
ld (hl),a
inc l
jr nz,GenY
inc h
ld a,%10000000
GenMask ld (hl),a
rrca
inc l
jr nz,GenMask
ld c,l
GenSub inc h
GenX ld a,l
and b
or c
rrca
rrca
rrca
ld (hl),a
inc l
jr nz,GenX
inc c
bit 3,c
jr z,GenSub
Let's conclude the article with a "bonus" version that avoids using registers B and C. However, because the pixel masks need to be replicated eight times, the resulting memory footprint of 4.5 KiB makes it usable only in the most hardcore scenarios:
7 ld h,TAB
4 ld l,d ; L = Y coordinate
7 ld d,(hl) ; B = 0 1 0 y7 y6 y2 y1 y0
4 inc h
7 ld h,(hl) ; one of 8 sub-tables based on y5 y4 y3
4 ld l,e ; L = X coordinate
7 ld e,(hl) ; E = y5 y4 y3 x7 x6 x5 x4 x3
4 inc h ; move to the adjacent pixel mask table
7 ld a,(de)
7 or (hl) ; apply pixel mask
7 ld (de),a
Happy pixel plotting!
baze / 3SC
Share your thoughts about the article