Blog

Dots tunnel: a 256 × 256 screen and 400 dots

31/10/2026demo effectsdots tunnelCPCZ80

The fourth post fit the tunnel into one frame, with a dot at 43 NOPs. In that dot, going from y to the address is still the most expensive step, 19 NOPs out of 43, between two table reads and an addition with carry.

That carry comes from the width of the screen: a CPC line takes 80 bytes, and that number is what forces the addition. No instruction can change that, but the shape of the screen can be set. So the v6 programs the CRTC for a 256 × 256 screen, where the addition becomes an or, with no carry. The v7 then spends the time saved on dots, until the frame is almost full.

What an 80-byte line costs

On the standard screen, line y starts 80*(y>>3) + 2048*(y and 7) bytes after the start of the screen.

The low byte of 80*(y>>3) goes from 0 to 240, depending on the row. Adding the column of the dot (x >> 2) can therefore overflow into the high byte, and the v5 has to chain add a,c and adc a,(hl). Since the high byte depends on that carry, the v5 cannot read it straight from a table.

With 64 bytes per line, line y starts at 64*(y>>3) + 2048*(y and 7). The low byte of 64*(y>>3) is always a multiple of 64: its 6 low bits are 0. A column goes from 0 to 63 and fills exactly these 6 bits. The addition becomes an or, which never overflows, and the high byte now depends on y alone.

A 64-byte line holds 256 pixels in mode 1, and x fits in a byte. With 256 lines, y fits in a byte too, and 64 × 256 = 16,384: the screen fills its 16 KB page exactly. Both screens therefore stay at #C000 and #8000.

Setting the CRTC

The CRTC, the chip that produces the video memory addresses and the monitor syncs, is set through its registers, and four of them change here:

init writes these four registers at the end, after the palette. A pair of ports is enough: #BC selects the register, #BD writes the value into it:

        ld hl,crtc_256
        ld e,4
next_reg:
        ld b,#BC                ; CRTC: select the register
        ld a,(hl)
        out (c),a
        inc hl
        inc b                   ; #BD: write into it
        ld a,(hl)
        out (c),a
        inc hl
        dec e
        jr nz,next_reg

The table after the code gives, for each register, its number and then its value:

; R1 = 32 words (64 bytes) per line, R6 = 32 rows of 8 lines; R2 and R7
; move the syncs to keep the picture in the middle of the monitor.
crtc_256:
        db 1,32, 2,42, 6,32, 7,34

show_screen does not change: R12 still selects the displayed screen, #C000 or #8000.

Tables computed by RASM

TABLES.BIN no longer fits. Its YLO and YHI tables follow 80-byte lines, and CENTER puts the tunnel at (160, 100), the centre of the standard screen. The second post showed that RASM computes the same tables as the BASIC, byte for byte. So the v6 hands all the tables over to it, except XMASK and COLX, which make_x_tables still builds at start-up. The .sna then holds everything, without the file made by the BASIC.

The tunnel keeps its parameters and moves its centre to (128, 128). Its dots then go from 38 to 218 on both axes, and all stay on the screen.

Since the high byte depends on y alone, YHI can give it whole, screen start included. Each screen then has its own pair of tables, and here is the pair of the #C000 screen:

; A line takes 64 bytes: line y starts at 64*(y>>3) + 2048*(y and 7). The low
; byte is a multiple of 64, and a byte column (0 to 63) fills its 6 low bits.
        org YLO_C0
        repeat 256,y,0
        db ((y>>3)*64)&255
        rend
        repeat 256,y,0          ; YHI_C0 = YLO_C0 + 256
        db SCREEN_C0+(((y>>3)*64+(y&7)*2048)>>8)
        rend

The pair of the #8000 screen, at #6A00, differs only by SCREEN_80.

A dot at 39 NOPs

The v6 dot reads the low byte of its address in YLO, then the high byte in YHI, at the same position y:

        repeat DOTS,n,0         ; the 16 dots, one after the other
        ld a,(de)               ; dx
        inc e                   ; the 32 bytes of a ring stay in one page
        add a,ixl               ; x = ring centre x + dx
        ld l,a
        ld h,XMASK>>8
        ld a,(hl)               ; bits of the pixel in its byte
        and b                   ; in the ink
        ex af,af'               ; kept aside for the end
        inc h                   ; COLX = XMASK + 256
        ld c,(hl)               ; byte column: x >> 2
        ld a,(de)               ; dy
        inc e
        add a,ixh               ; y = ring centre y + dy
        ld l,a
ypatch{n}:
        ld h,0                  ; YLO page of the work screen, set by main
        ld a,(hl)
        or c                    ; low byte of the line + column: no carry
        inc h                   ; YHI = YLO + 256
        ld h,(hl)               ; high byte, screen start included
        ld l,a
        ex af,af'               ; the bits of the pixel again
        or (hl)                 ; keep the other pixels of the byte
        ld (hl),a
        push hl                 ; record the address for the clearing
        rend

After ld a,(hl), L still holds y. inc h moves from YLO to YHI, and ld h,(hl) puts the high byte straight into H. The v5 had to keep the low byte in C, read the screen start from IYH, then add with the carry. Here, going from y to the address costs 7 NOPs instead of 11. The or itself costs 1 NOP, as much as the add it replaces, and the 4 NOPs saved all come from the high byte, which the table gives as is.

Code that rewrites itself

We still have to give the dot the pair of tables of the work screen, which changes for every picture. The dot finds it in the operand of ld h,0, which main rewrites before drawing. This is self-modifying code: the program changes one byte of its own instructions.

RASM numbers the 16 copies of the dot: inside repeat DOTS,n,0, the label ypatch{n} becomes ypatch0, ypatch1, up to ypatch15. The label points to the opcode, and the operand sits in the next byte, hence the +1. For every picture, main switches the page along with the screen, then writes the new page into the 16 copies:

        ld a,(ypage)
        xor (YLO_C0>>8)^(YLO_80>>8)
        ld (ypage),a            ; the YLO table of the work screen
        repeat DOTS,n,0
        ld (ypatch{n}+1),a      ; goes into the ld h of each dot
        rend

These 16 writes cost 64 NOPs per picture. The v5 paid 2 NOPs per dot to read IYH, that is 512 per picture, and IYH is no longer used.

The dot takes 39 NOPs:

Step v5 v6
x: bits of the pixel and column 15 15
y 8 8
address 11 7
write and list 9 9
total 43 39

The dot saves 4 NOPs, that is 1,024 per picture. The work of a picture goes from about 14,000 to about 13,000 NOPs: 1,280 for the clearing, 256 × 39 = 9,984 for the dots, 1,656 to set up the 16 rings.

The v6 draws the same tunnel as the v1. We checked it in a Z80 emulator: for all 256 values of t, the dots lit by the v6 are those of the v1, moved 32 pixels to the left and 28 down.

400 dots

The v6 leaves about 6,800 NOPs free in the frame, and the v7 spends them on dots.

Adding rings does not work, as the fourth post showed: 16 rings spaced 4 depths apart already cover all 64, and a seventeenth would land on the first. So the v7 adds dots to each ring, 25 instead of 16, that is 400 in all.

The shape of a ring

Dot k of a ring stays at angle 4k + rot, but a turn now has 4 × 25 = 100 steps instead of 64. Four rotation phases are still enough: after 4 steps, each dot has taken the place of its neighbour.

A (d, rot) row of SHAPE now holds 50 bytes. The v7 gives it 64 bytes. A row thus never crosses its 256-byte page, and inc e is enough to move on. defs pads each row with 14 zero bytes:

        org SHAPE
        repeat 64,d,0
s = P*R/(Z0+d*DZ)
        repeat 4,rot,0
        repeat DOTS,k,0
angle = 360*(4*k+rot)/(4*DOTS)
        db floor(s*cos(angle)+0.5)&255
        db floor(s*sin(angle)+0.5)&255
        rend
        defs ROW-2*DOTS,0       ; a row never crosses a page: inc e is enough
        rend
        rend

The four rows of a depth take 256 bytes, a whole page. The address of a row becomes SHAPE + 256 × d + 64 × rot: D gets the high byte of SHAPE plus d, and E gets rot × 64. The v6, like the v5, still tested whether d was odd. The v7 computes the address of the row without that test:

        ld a,(t)                ; DE = SHAPE + d * 256 + rot * 64
        and #0C
        rlca
        rlca
        rlca
        rlca                    ; rot * 64, rot = (t >> 2) and 3
        ld e,a
        ld a,c
        add a,SHAPE>>8
        ld d,a

Setting up a ring thus drops from 103.5 to 99 NOPs: the test cost 5.5 NOPs on average, and the fourth rlca costs only one.

Memory

SHAPE grows to 16 KB, 64 depths of 256 bytes. It takes the whole area from #4000 to #7FFF, just below the #8000 screen. The other tables move down between #2000 and #2DFF, above the code, which ends around #1770. The two erase lists now keep 400 addresses, 800 bytes each, at #3000 and #3400.

For the rest, changing DOTS is enough: repeat RINGS*DOTS unrolls 400 clears, repeat DOTS,n,0 copies the dot 25 times, and main writes 25 operands instead of 16.

The budget

The count by hand gives, per picture:

Item Computation NOPs
clearing 400 × 5 2,000
dots 400 × 39 15,600
ring set-up 16 × 99 1,584
total work about 19,200

With the main loop and the 25 operands to rewrite, a picture takes about 19,450 NOPs, 97% of a frame. About 500 NOPs are left. A 26th dot per ring would cost 16 × 44 = 704 NOPs (39 to draw it, 5 to clear it) and would no longer fit.

The v7 draws what its tables describe: for all 256 values of t, the Z80 emulator finds exactly the dots that CENTER and SHAPE predict.

The tunnel on the CPC

The CPCEC emulator below runs tunnel-v7.sna, assembled from tunnel-v7.asm:

The screen is now square, with a border on the left and on the right.

What each version brings

We measured the three versions as in the previous posts, in frames per picture in amspirit-lite-headless (6128), over 1,000 frames:

Version Dots Frames per picture Pictures per second
v5: standard screen 256 1 50
v6: 256 × 256 screen 256 1 50
v7: 25 dots per ring 400 1 50

Conclusion

The 256 × 256 screen brings the dot down to 39 NOPs, and the v7 shows 400 dots at 50 pictures per second, 144 more than the v5, with these limits:

The next post changes method: instead of one routine shared by all the dots, it produces code compiled for this very tunnel.

The files of this post:

Both versions assemble with RASM 3.0.9, without a table file.