Blog

Dots tunnel: from the tables to a first Z80 tunnel

28/10/2026demo effectsdots tunnelCPCZ80

The first post ended on two byte tables, SHAPE and CENTER, computed in JavaScript. With them, the tunnel loop finds the position (x, y) of each dot with a few memory reads and additions. But to draw the dot, it still relies on g.rect(x, y, 1, 1), a PixiJS function.

On the CPC, the program must write each dot into video memory itself. This post goes all the way to the tunnel on the CPC screen, in three stages. First the CPC computes the tables in Locomotive BASIC (the BASIC built into the machine) and saves them to a file. Then RASM computes the same tables at assembly time, and must find this file again byte for byte. Last, a first version in Z80 loads the file and draws the tunnel, without trying to be fast, and we measure where its time goes. But the way the CPC lays out its pixels calls for one more table, and that is where it all starts.

The CPC screen memory

The CPC has no separate video memory: the screen takes 16 KB of ordinary memory, from &C000 to &FFFF (in BASIC, & marks a hexadecimal number). To light a pixel, the program writes to this area like to any other memory location.

One byte, four pixels

The tunnel runs in mode 1: 320 × 200 pixels and four inks. Choosing one ink out of four takes 2 bits, so an 8-bit byte holds 4 pixels.

And these bits are not next to each other. The leftmost pixel uses bits 7 and 3, the next one bits 6 and 2, then come 5 and 1, and finally 4 and 0:

Bit of the byte 7 6 5 4 3 2 1 0
Pixel 0 1 2 3 0 1 2 3

To draw pixel x, we must therefore find the byte that holds it, then the two bits of the pixel in that byte.

One line, 80 bytes

A line of 320 pixels takes 320 / 4 = 80 bytes. Pixel x is in byte x \ 4 of its line (\ is the integer division of BASIC). Its place in the byte is the remainder of this division, x AND 3: 0 for the leftmost pixel, 3 for the rightmost one.

Interleaved lines

You would expect line 1 of the screen right after line 0, 80 bytes further on, but the CPC orders its lines differently. Its screen is made of 25 character rows, of 8 lines each. Lines 0 of the 25 rows follow each other in memory, lines 1 start 2,048 bytes further on (&800), and so on up to lines 7.

For line y, the row is y \ 8 and the line within the row is y AND 7. Line y therefore starts at address &C000 + (y\8)*80 + (y AND 7)*2048.

One pixel

Put these three rules together, and pixel (x, y) is in the byte at address:

&C000 + (y\8)*80 + (y AND 7)*2048 + x\4

For example, pixel (100, 50) is in row 6, on line 2 of this row, and in byte 25 of its line. Its address is &C000 + 480 + 4096 + 25, that is &D1F9. And since 100 AND 3 is 0, it is the leftmost pixel of this byte: it uses bits 7 and 3.

Why a table for the lines

The Z80 must do this calculation for each dot, and the tunnel has 256 of them per picture (16 rings of 16 dots).

The x part costs it almost nothing. Dividing by 4 means shifting the bits two places to the right, and x AND 3 keeps the two low bits, which give the place of the pixel in the byte.

The y part is another matter. The Z80 can add and shift bits, but it has no multiply instruction: the *80 becomes a series of shifts and additions, on 2-byte numbers.

So we use the recipe of the first post again: compute once and for all, in advance, and store the results in a table. For each line y, the table gives (y\8)*80 + (y AND 7)*2048. This number goes up to 16 KB and does not fit in a byte, while a table stores only one byte per entry. So it is split in two, the low byte in YLO and the high byte in YHI.

The start of the screen, &C000, stays out of the table, and the Z80 will add it when it draws: the same table will then serve for a screen placed elsewhere in memory.

A table could also replace the x calculation. We keep it for later: without a measurement, nothing says it is worth it.

Tables aligned on 256

A CPC address takes 2 bytes, a high byte and a low byte. Address &4023 has the high byte &40 and the low byte &23.

Take YLO at address &4000. Entry y is at address &4000 + y, and since y stays below 256, this addition only touches the low byte: &4000 + &23 gives &4023. So the Z80 has nothing to add. It puts &40 in the high byte, y in the low byte, and reads the entry.

If YLO started at &4080, entry &90 would land at &4110, and the high byte would change. The Z80 would then need a real 2-byte addition, for each dot.

That is why each table starts at an address that is a multiple of 256. The rule has a price (a table indexed by y has 256 entries at most), but it does no harm here: the screen has 200 lines, and the dots of the tunnel stay between y = 10 and y = 190. We checked these bounds on every entry of SHAPE and CENTER.

CENTER and SHAPE are larger, but they too start at a multiple of 256. The four tables take 10.5 KB from address &4000:

Table Address Size Content
YLO &4000 256 the address of a line, low byte
YHI &4100 256 the address of a line, high byte
CENTER &4200 2,048 the centres of the rings
SHAPE &4A00 8,192 the shapes of the rings

The tables in Locomotive BASIC

The program draws nothing. It fills memory, byte by byte, with POKE, then it saves the result.

BASIC also keeps its variables in memory. MEMORY &3FFF stops it from going past address &3FFF, and the table area stays free for the program. Lines 30 and 40 give the constants of the JavaScript and the address of each table:

10 MODE 1:MEMORY &3FFF
20 t=TIME
30 CX=160:CY=100:P=90:R=1:Z0=1:DOTS=16:AX=72:AY=50
40 YLO=&4000:YHI=&4100:CENTER=&4200:SHAPE=&4A00

TIME counts in 300ths of a second. Line 20 notes the start time, to show the duration at the end.

The names are those of the first post, and for CENTER and SHAPE, the listing reads side by side with the last playground, loop for loop. One difference: in JavaScript, SHAPE and CENTER are arrays, while here they are addresses, and POKE SHAPE+n stands for SHAPE[n].

YLO and YHI

Line 60 computes the address of line y, without the start of the screen. Line 70 splits it in two: v AND 255 keeps the low byte, v \ 256 gives the high byte. Lines 200 to 255 do not exist, and their entries stay at 0:

50 PRINT "YLO, YHI"
60 FOR y=0 TO 255:v=0:IF y<200 THEN v=(y\8)*80+(y AND 7)*2048
70 POKE YLO+y,v AND 255:POKE YHI+y,v\256:NEXT

CENTER

CENTER is the table of the first post, line for line: 16 levels q, 64 phases phase, two bytes per entry:

80 PRINT "CENTER"
90 FOR q=0 TO 15:FOR phase=0 TO 63:n=(q*64+phase)*2
100 POKE CENTER+n,CX+INT(AX*q/15*SIN(2*PI*phase/64)+0.5)
110 POKE CENTER+n+1,CY+INT(AY*q/15*SIN(4*PI*phase/64+0.7)+0.5)
120 NEXT:PRINT ".";:NEXT:PRINT

The PRINT "."; of line 120 prints a dot for each level: without it, nothing moves on the screen during the whole calculation.

Rounding

JavaScript rounds with Math.round. BASIC does have ROUND, but the two disagree when a number falls exactly halfway between two integers: Math.round(2.5) gives 3 and ROUND(2.5) gives 2, Math.round(-2.5) gives −2 and ROUND(-2.5) gives −3.

INT, for its part, keeps the integer just below: INT(2.7) gives 2, and INT(-2.3) gives −3. Adding 0.5 before INT therefore rounds like Math.round, halves included, and that is what lines 100 and 110 do.

SHAPE

SHAPE is the largest table: 64 depths d, 4 rotations rot, 16 dots k and two bytes per dot, that is 4,096 cosines and as many sines. The calculation follows the JavaScript one, except for two details that the next section explains: the radius s on line 140, and the sine on line 170.

130 PRINT "SHAPE"
140 FOR d=0 TO 63:s=P*R/(Z0+d*3/20)
150 FOR rot=0 TO 3:FOR k=0 TO DOTS-1:angle=2*PI*(4*k+rot)/64:n=((d*4+rot)*DOTS+k)*2
160 POKE SHAPE+n,INT(s*COS(angle)+0.5) AND 255
170 POKE SHAPE+n+1,INT(s*COS(angle-PI/2)+0.5) AND 255
180 NEXT:NEXT:PRINT ".";:NEXT:PRINT

Negative bytes

Each entry of SHAPE stores an offset from the centre of the ring, dx or dy, from −90 to 90. A dot left of the centre has a negative dx, a dot above it a negative dy. But POKE writes only one byte, from 0 to 255: a negative number must therefore be encoded in a byte.

The Z80 follows the same rule as the JavaScript Int8Array, two’s complement: a byte stores −n as 256 − n. −1 becomes 255, −22 becomes 234, and −90 becomes 166. The numbers from 0 to 127 keep their value, and the bytes from 128 to 255 encode the numbers from −128 to −1.

BASIC does this calculation by itself. It stores its integers on 2 bytes, with the same rule: there, −22 becomes 65,536 − 22, that is &FFEA. AND 255 keeps the low byte, &EA, that is 234, and this is what lines 160 and 170 do.

Two floating-point traps

To check the program, we compared each byte with the tables of the first post, computed in JavaScript. Copied as it stands, the SHAPE calculation gave 2 wrong bytes out of 8,192, both at depth d = 20. At this depth, the radius s is exactly 22.5, and a dot 22.5 pixels from the centre falls exactly halfway between two pixels: the slightest calculation error tips it one way or the other.

The first trap comes from the depth. The JavaScript writes it Z0 + d * DZ, with DZ = 0.15, but the CPC stores its numbers in binary, and 0.15 has no exact binary form (no more than 1/3 has an exact decimal one). For d = 20, the radius 90/(1+20*0.15) then comes out as 22.4999999925 instead of 22.5, and the rounding gives 22 instead of 23. So line 140 writes DZ as 3/20, and in d*3/20, BASIC multiplies before it divides: for d = 20, 60/20 gives exactly 3, and the radius is back to 22.5.

The second trap comes from the sine. On the CPC, SIN(PI/2) is not quite 1 (it misses it by a little more than 2 ten-billionths), while COS(0) is exactly 1. A sine is only a cosine shifted by a quarter turn, so line 170 computes COS(angle-PI/2) instead of SIN(angle). At the quarter turn, the cosine is taken at 0 and gives a true 1. SIN in degrees, after DEG, makes the same error.

In JavaScript, both calculations come out exact: 1 + 20 * 0.15 gives 4 there, and Math.sin(Math.PI / 2) gives 1, because it computes with more digits. And these errors only affect the dots that fall exactly halfway between two pixels. Elsewhere, the dot closest to a halfway point is still 6 hundred-thousandths of a pixel away from it, far more than the error of the CPC.

Saving the tables

What remains is to keep the tables. Nobody wants to wait 5 minutes each time the tunnel starts, so the program saves the 10,752 bytes to a binary file, TABLES.BIN. The B of SAVE asks for a binary file, followed by the start address and the length, &2A00, that is 256 + 256 + 2,048 + 8,192 bytes:

190 PRINT "DUREE :";ROUND((TIME-t)/300,1);"S"
200 SAVE "tables",B,&4000,&2A00

AMSDOS puts a 128-byte header in front of the bytes, with the name, the address and the length of the file. The file therefore takes 10,880 bytes on the disc. Line 190 prints the time taken first (DUREE is French for duration).

The complete program

10 MODE 1:MEMORY &3FFF
20 t=TIME
30 CX=160:CY=100:P=90:R=1:Z0=1:DOTS=16:AX=72:AY=50
40 YLO=&4000:YHI=&4100:CENTER=&4200:SHAPE=&4A00
50 PRINT "YLO, YHI"
60 FOR y=0 TO 255:v=0:IF y<200 THEN v=(y\8)*80+(y AND 7)*2048
70 POKE YLO+y,v AND 255:POKE YHI+y,v\256:NEXT
80 PRINT "CENTER"
90 FOR q=0 TO 15:FOR phase=0 TO 63:n=(q*64+phase)*2
100 POKE CENTER+n,CX+INT(AX*q/15*SIN(2*PI*phase/64)+0.5)
110 POKE CENTER+n+1,CY+INT(AY*q/15*SIN(4*PI*phase/64+0.7)+0.5)
120 NEXT:PRINT ".";:NEXT:PRINT
130 PRINT "SHAPE"
140 FOR d=0 TO 63:s=P*R/(Z0+d*3/20)
150 FOR rot=0 TO 3:FOR k=0 TO DOTS-1:angle=2*PI*(4*k+rot)/64:n=((d*4+rot)*DOTS+k)*2
160 POKE SHAPE+n,INT(s*COS(angle)+0.5) AND 255
170 POKE SHAPE+n+1,INT(s*COS(angle-PI/2)+0.5) AND 255
180 NEXT:NEXT:PRINT ".";:NEXT:PRINT
190 PRINT "DUREE :";ROUND((TIME-t)/300,1);"S"
200 SAVE "tables",B,&4000,&2A00

The disc tables.dsk holds this program. In an emulator or on a real CPC, RUN"TABLES starts it, and the CPC takes a little under 5 minutes to fill the tables (284 seconds on an emulated 6128). The disc must accept writes, or the SAVE at the end fails.

To check the bytes, this line sums the whole table area. Each byte counts with a weight that depends on its address, so that a misplaced byte also changes the result:

c=0:FOR i=&4000 TO &69FF:c=c+PEEK(i)*((i AND 15)+1):NEXT:PRINT c

It must print 11232147, the sum of the tables computed in JavaScript by the first post, plus the line table.

The same tables with RASM

So the CPC can compute its own tables, but it takes almost 5 minutes, and it would have to start again for each changed constant. RASM, on the other hand, can compute during assembly: its language has variables, loops, conditions and the usual maths functions. It can therefore write the tables itself, at assembly time, into a file. RASM is not unique here: any decent assembler offers similar means, under other names (sjasmplus, for example, has DUP to repeat a block).

The file tables.asm starts with the constants of the JavaScript, then places the tables from #4000 (RASM writes hexadecimal with #, where BASIC uses &):

CX = 160
CY = 100
P = 90
R = 1
Z0 = 1
DZ = 0.15
DOTS = 16
AX = 72
AY = 50

        org #4000

; RASM's sin() and cos() take degrees: a full turn is 360, not 2*PI.
; floor(x+0.5) rounds like INT(x+0.5) in BASIC.

repeat 256,y,0 repeats the block up to rend 256 times, with y from 0 to 255, and each db there puts down one byte. The operators come from C: >> shifts right and & is a bitwise AND. So (y>>3)*80 is the (y\8)*80 of BASIC:

YLO:    repeat 256,y,0
        if y<200
          db ((y>>3)*80+(y&7)*2048)&255
        else
          db 0
        endif
        rend

YHI:    repeat 256,y,0
        if y<200
          db ((y>>3)*80+(y&7)*2048)>>8
        else
          db 0
        endif
        rend

CENTER and SHAPE follow the BASIC program loop for loop:

CENTER: repeat 16,q,0
        repeat 64,phase,0
          db CX+floor(AX*q/15*sin(360*phase/64)+0.5)
          db CY+floor(AY*q/15*sin(720*phase/64+0.7*180/pi)+0.5)
        rend
        rend

SHAPE:  repeat 64,d,0
s = P*R/(Z0+d*DZ)
        repeat 4,rot,0
        repeat DOTS,k,0
angle = 360*(4*k+rot)/64
          db floor(s*cos(angle)+0.5)&255
          db floor(s*sin(angle)+0.5)&255
        rend
        rend
        rend

Two details change. The sin and cos functions of RASM take degrees: a full turn is 360, and the 0.7 radian of CENTER becomes 0.7*180/pi. And floor, which keeps the integer just below, stands in for INT.

The two detours of BASIC, on the other hand, are no longer needed. RASM takes Z0+d*DZ with DZ = 0.15 and sin(angle), like the JavaScript, and lands exactly on the 4 dots of d = 20, because it computes with more digits than BASIC.

What remains is to save the result. $ is the current address, and $-YLO the length of the tables, #2A00:

        save "tables-rasm.bin",YLO,$-YLO

RASM assembles tables.asm and writes tables-rasm.bin in a hundredth of a second, against 284 seconds for BASIC. The BASIC file has 128 more bytes, the AMSDOS header. Without this header, the two files are identical, byte for byte.

RASM will make the tables of the versions that change the shape of the tunnel: change a constant, assemble, and the tunnel follows. BASIC keeps its role as the reference that runs on the machine itself. As long as the shape stays the same, the versions of the tunnel load its file, TABLES.BIN.

A first version in Z80

The v1 takes the loop of the last playground of the first post, in the most naive version possible. It runs without the CPC firmware, in a snapshot: a .sna file, which holds the image of the memory and registers of the machine. The emulator loads and starts it in one go, and RASM can write it by itself.

The start

buildsna asks RASM for a snapshot. The constants take the memory map of the BASIC program, the address of the screen and the names RINGS and DOTS from the JavaScript:

        buildsna
        bankset 0

YLO = #4000                     ; the memory map of tables.bas
YHI = #4100
CENTER = #4200
SHAPE = #4A00
SCREEN = #C000                  ; the screen that the snapshot shows
RINGS = 16
DOTS = 16

incbin copies the BASIC file into the memory of the snapshot, from YLO. The 128 skips the AMSDOS header. run start gives the address where the program starts:

        org YLO
        incbin "TABLES.BIN",128         ; skip the 128-byte AMSDOS header

        org #1000
        run start

start:  di
        ld sp,#1000
        call init

The snapshot holds only this program: the firmware has set up nothing in memory. So di turns off the interrupts, which nobody would handle. The stack starts at #1000 and grows down, below the code.

Setting up the machine

init talks to the Gate Array, the chip that builds the picture, through port #7Fxx. #8D selects mode 1 and turns off both ROMs. Then each ink gets its colour, with two out: the number of the ink, then its colour. #10 selects the border:

; init: mode 1, ROMs off, palette.
init:   ld bc,#7F8D
        out (c),c
        ld hl,palette
        ld e,0
next_ink:
        out (c),e               ; E < #10: select ink E
        ld a,(hl)
        out (c),a               ; #40 to #5F: set its colour
        inc hl
        inc e
        ld a,e
        cp 4
        jr nz,next_ink
        ld a,#10                ; select the border
        out (c),a
        ld a,BLACK
        out (c),a
        ret

The Gate Array takes each colour as #40 plus the number of the hardware colour, and the program names the ones it uses:

; Gate Array colours (#40 + hardware colour number)
BLACK = #54
BLUE = #44
SKY_BLUE = #57
BRIGHT_WHITE = #4B
RED = #4C

The palette takes the colours of the JavaScript: black for the background, then blue, sky blue and white. PENS stores, for each group of 16 depths, a byte of 4 pixels of the same ink:

palette:
        db BLACK,BLUE,SKY_BLUE,BRIGHT_WHITE
PENS:   db #FF,#0F,#F0,#F0      ; 4 pixels of ink 3, 2, 1, 1 for d >> 4

t:      db 0
depth:  db 0
ring:   db 0
dot:    db 0
ringX:  db 0
ringY:  db 0
pen:    db 0

These bytes come from the bit order of mode 1. Bit 7 gives the low bit of the ink of the leftmost pixel, and bit 3 its high bit. Ink 1 therefore sets only bits 7 to 4 (#F0), ink 2 bits 3 to 0 (#0F), and ink 3 all bits (#FF). The nearest ring is white, as in the JavaScript, and the farthest ones are blue.

The variables keep the names of the JavaScript, except d, which becomes depth: in assembly, d is already the name of a register.

Waiting for the VBL

The v1 draws straight into the visible screen, at #C000, which the RASM snapshot already shows at start. To keep a steady speed, each picture starts at frame flyback (the VBL), when the beam goes back to the top of the screen. wait_vbl reads bit 0 of port B of the PPI (#F5xx), which is 1 during the VBL. It first waits for the end of a VBL already in progress, then for the start of the next one:

; wait_vbl: wait for the start of the frame flyback.
wait_vbl:
        ld b,#F5                ; PPI port B, bit 0 = VBL
vbl_end:
        in a,(c)
        rra
        jr c,vbl_end            ; already in the VBL: wait for its end
vbl_start:
        in a,(c)
        rra
        jr nc,vbl_start
        ret

The loop

The main loop follows the playground. It waits for the VBL, clears the screen, moves t on, then draws the 16 rings of 16 dots. The border colour shows the work: red from the start to the end of the calculation, black while the program waits for the VBL. The border macro does the two out of the change. RASM copies its code at each call, with {colour} replaced by the given colour:

        macro border,colour
        ld bc,#7F10             ; Gate Array: select the border
        out (c),c
        ld a,{colour}
        out (c),a
        mend

border RED and border BLACK thus frame the work of each picture:

main:   call wait_vbl

        border RED              ; work starts

        ld hl,SCREEN            ; the visible screen: the picture flickers
        call clear16k

        ld hl,t
        inc (hl)
        ld a,(hl)
        neg
        and 63
        ld (depth),a            ; ring 0: d = -t and 63

        ld a,RINGS
        ld (ring),a
ring_loop:
        call ring_setup         ; ringX, ringY, pen, and IX = SHAPE of the ring
        ld a,DOTS
        ld (dot),a
dot_loop:
        ld a,(ringX)
        add a,(ix+0)            ; x = ringX + dx
        ld b,a
        ld a,(ringY)
        add a,(ix+1)            ; y = ringY + dy
        ld c,a
        ld a,(pen)
        ld e,a
        call plot
        inc ix
        inc ix
        ld hl,dot
        dec (hl)
        jr nz,dot_loop

        ld a,(depth)            ; next ring: d + 4
        add a,4
        and 63
        ld (depth),a
        ld hl,ring
        dec (hl)
        jr nz,ring_loop

        border BLACK            ; work ends
        jp main

clear16k uses an old Z80 trick. ld (hl),0 clears the first byte, then ldir copies each byte onto the next one: 16,383 copies, and the 16 KB are at 0:

clear16k:                       ; clear 16 KB from HL (L = 0)
        ld d,h
        ld e,1
        ld (hl),0
        ld bc,#3FFF
        ldir
        ret

t fits in a byte and goes back to 0 after 255. The tunnel does not mind: it only uses t and 63 and (t >> 2) and 3, which repeat every 64 pictures, and 256 is a multiple of 64.

The first ring is at depth -t and 63, like (4 * i - t) & 63 for i = 0 in the JavaScript, and each next ring is 4 depths further on.

One ring

ring_setup does for one ring what these lines of the JavaScript do:

const c = ((d >> 2) * 64 + (((t + d) >> 2) & 63)) * 2;
const ringX = CENTER[c], ringY = CENTER[c + 1];
const base = (d * 4 + rot) * DOTS * 2;
const color = COLORS[d >> 4];

The multiplications become shifts, and the alignment on 256 does the rest:

; ring_setup: read the centre, the ink and the shape address of ring d.
ring_setup:
        ld a,(depth)
        ld c,a
        and #3C                 ; (d >> 2) * 4
        ld l,a
        ld h,0
        add hl,hl
        add hl,hl
        add hl,hl
        add hl,hl
        add hl,hl               ; (d >> 2) * 128
        ld a,(t)
        add a,c
        and #FC
        rrca                    ; phase * 2, phase = ((t + d) >> 2) and 63
        or l
        ld l,a
        ld a,h
        add a,CENTER>>8
        ld h,a
        ld a,(hl)
        ld (ringX),a
        inc hl
        ld a,(hl)
        ld (ringY),a

        ld a,c                  ; pen = PENS(d >> 4)
        rlca
        rlca
        rlca
        rlca
        and 3
        ld e,a
        ld d,0
        ld hl,PENS
        add hl,de
        ld a,(hl)
        ld (pen),a

        ld a,(t)                ; IX = SHAPE + d * 128 + rot * 32
        and #0C
        rlca
        rlca
        rlca                    ; rot * 32, rot = (t >> 2) and 3
        ld e,a
        ld a,c
        srl a                   ; A = d >> 1, carry = d and 1
        jr nc,even_depth
        set 7,e                 ; + 128 if d is odd
even_depth:
        add a,SHAPE>>8
        ld d,a
        push de
        pop ix
        ret

For CENTER, and #3C keeps (d >> 2) * 4, and five add hl,hl multiply it again by 32: (d >> 2) * 128. The phase comes from t + d. and #FC clears the two low bits, and rrca moves the bits one place to the right: ((t + d) >> 2) * 2, the phase already multiplied by 2. This number stays below 128, and (d >> 2) * 128 has only 0 or 128 in its low byte, so an or is enough to join them. All that is left is to add CENTER >> 8 to the high byte.

For SHAPE, the row of the ring is at d * 128 + rot * 32. d * 128 is d >> 1 in the high byte, and bit 0 of d in bit 7 of the low byte. srl a does both at once: bit 0 of d goes out into the carry, and set 7,e puts it back in its place.

One dot

plot gets x in B, y in C and the ink byte in E, and sets the pixel:

; plot: set pixel (B, C) with ink E (4 pixels of the same ink).
plot:   ld a,b
        and 3                   ; place of the pixel in its byte
        ld d,#88                ; bits of the left pixel
        jr z,mask_ink
next_pixel:
        srl d
        dec a
        jr nz,next_pixel
mask_ink:
        ld a,d
        and e
        ld e,a                  ; bits of the pixel, in the ink

        ld a,b
        srl a
        srl a                   ; byte column: x >> 2
        ld h,YLO>>8
        ld l,c
        add a,(hl)              ; + low byte of the line
        ld b,a
        inc h                   ; YHI = YLO + 256
        ld a,SCREEN>>8
        adc a,(hl)              ; + high byte, carry and screen start
        ld h,a
        ld l,b
        ld a,e
        or (hl)                 ; keep the other pixels of the byte
        ld (hl),a
        ret

The pixel mask starts from #88, bits 7 and 3 of the leftmost pixel, and moves one place to the right for each pixel: #44, #22, #11. An and with the ink byte keeps only the bits of that ink.

The address comes from two additions. x >> 2 plus YLO gives the low byte, and the carry of this addition goes into the high byte through adc, which adds YHI and SCREEN>>8, the high byte of the screen start (#C0), at the same time. Last, or (hl) keeps the other pixels of the byte: two nearby dots can fall into the same byte.

The tunnel on the CPC

The CPCEC emulator below runs tunnel-v1.sna, assembled from tunnel-v1.asm and TABLES.BIN:

The tunnel turns, with the curve and the colours of the JavaScript. The visible screen, read from the memory of the emulator and compared byte for byte with the picture computed from the tables, shows no difference. But it moves slowly, the border stays red almost all the time, and the picture flickers: clear16k clears the visible screen for almost 5 frames, before the dots come back.

Where the time goes

A CPC frame lasts 19,968 NOPs (one NOP is 1 µs: 312 lines of 64 µs). To show 50 frames per second, all the work for one picture must fit in this budget. We measured the number of frames per picture in the amspirit-lite emulator (6128), over 1,000 frames, for the v1 and for two cut-down versions:

Version Frames per picture
full v1 7
clearing only, no ring 5
256 dots, no clearing 2

The program always waits for the next VBL, so a picture costs a whole number of frames. The v1 runs at 50 / 7, a little over 7 pictures per second.

Clearing alone takes almost 5 frames. ldir copies a byte in 6 NOPs (5 for the last one), that is 16,382 × 6 + 5 = 98,297 NOPs for 16 KB. Even without any dot, the v1 could not go past 10 pictures per second. The 256 dots cost between 1 and 2 frames, more than 78 NOPs per dot: a call, variables in memory, a shift loop for the mask.

So drawing fewer dots will not be enough: with this clearing, no number of dots holds 50 pictures per second. Yet the tunnel touches 256 bytes at most per picture, out of the 16,384 of the screen.

Conclusion

The tunnel runs on the CPC, with tables that BASIC and RASM compute identically. The v1 has the faults of its aim, which was to be easy to read:

The next post will deal with the clearing and the flicker, and the one after it with the cost of each dot.

The files of this post: