Skip to content

Draw Pixels, Spans and Boxes on Spectrum 48K

Callable set, clear, XOR, read, span and rectangle routines for Spectrum 48K, with explicit bounds and byte ownership.

A life bar, a room outline or a tiny drawing tool needs a few reliable drawing calls. These routines let you change a bit, read it back, or paint a box without borrowing a game’s renderer.

Spectrum 48K output showing filled strips, thin and outlined boxes, bit-phase tests and a full-display border
Original caller running in Emu198x 0.39.0. The complete bitmap and selected native frames are checked independently.

Include it and make a first call

Define BP at 48 private RAM bytes, include z80.inc, then include the existing bitmap-addressing.inc. Set SP to a valid stack. The complete caller uses BP=$7000 and SP=$FF00; those are allocations to change deliberately in your game.

Initialise and clear the display once. This example then sets pixel (0,0); the context’s unused end fields need no initialisation for a point call. The complete reset caller also contains a mailbox used by the verifier. Your game calls the routine directly.

Set one pixel using the reusable component

demo.asm, excerpt
; Example: set pixel (0,0). Attributes belong to the caller.
    ld hl,0
    ld (BP),hl
    ld (BP+2),hl
    xor a
    ld (BP+8),a
    call bp_pixel

The screen origin is the top left. X increases rightwards and Y downwards. This mode is 256×192; the last pixel is (255,191).

The calling contract

Reserve 48 context bytes at BP. The word fields are little-endian on this CPU.

Offset What to supply or read
0, 2 x0, y0: unsigned 16-bit coordinates
4, 6 x1, y1: unsigned 16-bit exclusive ends
8 Operation: 0 set, 1 clear, 2 XOR
9 Output: 1 accepted, 0 rejected
10 Output: the bit read, as 0 or 1
12–47 Private scratch; word at 40 counts destination-byte writes

bp_fill paints [x0,x1)×[y0,y1); bp_box outlines it. bp_hspan uses x0/x1/y0, bp_vspan uses x0/y0/y1, and bp_pixel/bp_read use x0/y0. Set operation to zero for reads. Public inputs survive. Unused end fields are ignored.

A box from (5,20) to (13,24) is eight pixels wide and four high. A 1×1 outline touches its pixel once, including with XOR. Empty, reversed, invalid-operation or out-of-bounds requests reject before any destination write. The routine does not silently clip.

From a coordinate to a byte

The Spectrum is three pictures of 64 scanlines each, with rows shuffled again inside each third. At (13,20), pixel_addr returns $4441, and the mask is $04. The helper finds a byte; the new routine decides which bits may change. It leaves all 768 colour attributes untouched. Set INK/PAPER separately; changing a bit alone does not choose its colour.

Integer division and remainder tell us two different things: 13 div 8 = 1 chooses a byte column, while 13 mod 8 = 5 chooses a bit within it. The leftmost pixel uses bit 7, so 128 >> 5 = 4. Set ORs that mask into the old byte; clear ANDs its inverse; XOR toggles it. Writing $04 straight over the byte would erase seven neighbours.

Four rows of sixteen dots, with columns five through twelve highlighted across a byte boundary
[5,13) spans two bytes. Keep the neighbours on both edges, then repeat the masks over four rows.

Place a point explores position and origin. Its world Y points upwards; these screen coordinates point downwards. For integer points in a bottom-left-origin world of this height, convert with screenY = 191 - worldY. The width calculation needs no sign reversal: 13-5=8, height 24-20=4, area 8*4=32 pixels.

The maintained implementation

Open the complete reusable Z80 include

Include this backend once in your program

z80.inc
; SPDX-License-Identifier: MIT
; Spectrum 48K. BP reserves 48 bytes of RAM. Native little-endian public words:
; +0 x0,+2 y0,+4 x1,+6 y1 (exclusive ends); +8 op 0=set/1=clear/2=XOR.
; +9 accepted,+10 read value,+40 word destination-write count. +12..47 scratch.
; Inputs survive; destroys AF/BC/DE/HL, flags; stack <=8 bytes including caller.
; Requires existing pixel_addr (D=y,E=x), no attribute writes, not re-entrant.
bp_fill:
    xor a
    jr bp_enter
bp_box:
    ld a,1
    jr bp_enter
bp_pixel:
    ld a,2
    jr bp_enter
bp_read:
    ld a,3
    jr bp_enter
bp_hspan:
    ld a,4
    jr bp_enter
bp_vspan:
    ld a,5
bp_enter:
    ld (BP+12),a
    xor a
    ld (BP+9),a
    ld (BP+10),a
    ld (BP+40),a
    ld (BP+41),a
    ld hl,BP
    ld de,BP+16
    ld bc,8
    ldir
    ld a,(BP+12)
    cp 2
    jr c,bp_validate
    cp 5
    jr z,bp_unit_x
    ld hl,(BP+18)
    inc hl
    ld (BP+22),hl
    cp 4
    jr z,bp_validate
bp_unit_x:
    ld hl,(BP+16)
    inc hl
    ld (BP+20),hl
bp_validate:
    ld a,(BP+8)
    cp 3
    ret nc
    ld hl,(BP+20)
    ld de,257
    or a
    sbc hl,de
    ret nc
    ld hl,(BP+16)
    ld de,(BP+20)
    or a
    sbc hl,de
    ret nc
    ld hl,(BP+22)
    ld de,193
    or a
    sbc hl,de
    ret nc
    ld hl,(BP+18)
    ld de,(BP+22)
    or a
    sbc hl,de
    ret nc
    ld a,1
    ld (BP+9),a
    ld a,(BP+16)
    and 7
    ld e,a
    ld d,0
    ld hl,bp_right_masks
    add hl,de
    ld a,(hl)
    ld (BP+24),a
    ld hl,bp_bit_masks
    add hl,de
    ld a,(hl)
    ld (BP+26),a
    ld a,(BP+20)
    and 7
    ld e,a
    ld hl,bp_left_masks
    add hl,de
    ld a,(hl)
    ld (BP+25),a
    ld hl,(BP+20)
    dec hl
    ld a,l
    and 7
    ld e,a
    ld hl,bp_bit_masks
    add hl,de
    ld a,(hl)
    ld (BP+27),a
    ld a,(BP+16)
    srl a
    srl a
    srl a
    ld (BP+28),a
    ld hl,(BP+20)
    dec hl
    ld a,l
    srl a
    srl a
    srl a
    ld (BP+29),a
    ld a,(BP+18)
    ld (BP+32),a
    ld a,(BP+22)
    dec a
    ld (BP+42),a
bp_row:
    xor a
    ld (BP+39),a
    ld a,(BP+32)
    ld d,a
    ld a,(BP+16)
    ld e,a
    call pixel_addr
    ld (BP+34),hl
    ld a,(BP+28)
    ld (BP+30),a
    ld a,(BP+24)
    ld (BP+36),a
    ld a,(BP+25)
    ld (BP+37),a
    ld a,(BP+12)
    cp 1
    jr nz,bp_bytes
    ld a,(BP+18)
    ld b,a
    ld a,(BP+32)
    cp b
    jr z,bp_bytes
    ld a,(BP+42)
    ld b,a
    ld a,(BP+32)
    cp b
    jr z,bp_bytes
    ld a,1
    ld (BP+39),a
    ld a,(BP+26)
    ld (BP+36),a
    ld a,(BP+27)
    ld (BP+37),a
bp_bytes:
    ld c,255
    ld a,(BP+28)
    ld b,a
    ld a,(BP+30)
    cp b
    jr nz,bp_not_first
    ld a,(BP+36)
    ld c,a
bp_not_first:
    ld a,(BP+29)
    ld b,a
    ld a,(BP+30)
    cp b
    jr nz,bp_not_last
    ld a,(BP+37)
    and c
    ld c,a
    ld a,(BP+39)
    or a
    jr z,bp_not_last
    ld a,(BP+28)
    cp b
    jr nz,bp_not_last
    ld a,(BP+36)
    ld b,a
    ld a,(BP+37)
    or b
    ld c,a
bp_not_last:
    ld a,(BP+39)
    or a
    jr z,bp_apply
    ld a,(BP+30)
    ld b,a
    ld a,(BP+28)
    cp b
    jr z,bp_apply
    ld a,(BP+29)
    cp b
    jr nz,bp_advance
bp_apply:
    ld hl,(BP+34)
    ld a,(BP+12)
    cp 3
    jr nz,bp_write
    ld a,(hl)
    and c
    ret z
    ld a,1
    ld (BP+10),a
    ret
bp_write:
    ld a,(BP+8)
    or a
    jr z,bp_set
    cp 1
    jr z,bp_clear
    ld a,(hl)
    xor c
    jr bp_store
bp_clear:
    ld a,c
    cpl
    and (hl)
    jr bp_store
bp_set:
    ld a,c
    or (hl)
bp_store:
    ld (hl),a
    ld hl,(BP+40)
    inc hl
    ld (BP+40),hl
bp_advance:
    ld a,(BP+29)
    ld b,a
    ld a,(BP+30)
    cp b
    jr z,bp_next_row
    inc a
    ld (BP+30),a
    ld hl,(BP+34)
    inc hl
    ld (BP+34),hl
    jp bp_bytes
bp_next_row:
    ld a,(BP+22)
    ld b,a
    ld a,(BP+32)
    inc a
    cp b
    ret z
    ld (BP+32),a
    jp bp_row
bp_right_masks:
    db 255,127,63,31,15,7,3,1
bp_left_masks:
    db 255,128,192,224,240,248,252,254
bp_bit_masks:
    db 128,64,32,16,8,4,2,1

Choose a path for the work

The 32×8 aligned fill in the native check makes 32 byte writes; shifting it one pixel makes 40. The eight-pixel-wide example above makes eight writes for its 32 pixels. Calling a plot routine for each pixel would repeat addressing and edge work. This backend works a byte at a time, preserving the first and last byte’s unrelated bits.

A full-display clear makes 6,144 writes. The kernel, including its masks, takes 489 assembled bytes, plus the 44-byte existing addressing helpers; BP needs 48 RAM bytes. Assets/display memory and the reset caller are additional. These are measured storage and write counts, not a frame-budget or cross-CPU speed ranking. The write counter itself also costs work.

An unchecked inner loop, cached row address or specialised full-byte store can be smaller/faster for a particular caller. This version still reads a byte before a full-byte set/clear; an outline skips stores to middle columns but still visits them. Clipped lines, circles and ellipses add a reusable geometry layer over this unchanged backend.

Own the resources

AF/BC/DE/HL and flags are clobbered. The context is not re-entrant. Reserve at least eight stack bytes for the routines, plus the rest of your program and interrupts. Keep context, code and any address tables out of the bitmap; do not let another renderer or interrupt handler change them during a call. The routines do not change interrupt state. Colour, palette and video setup remain caller-owned.

What was verified

All 94 calls completed independently on this Spectrum 48K configuration using Asm198x 0.0.71 and Emu198x 0.39.0. Every call checks the complete bitmap, guards or attribute neighbours, status, read value, unchanged public inputs and byte-write count. Cases cover all bit phases, set/clear/XOR/read, spans, one-pixel and full-size boxes, empty/reversed extents and invalid bounds. Selected whole native frames match a separate composition. A completed wrong-mask version fails both memory and picture comparisons.

This is an original reset fixture. It establishes these calls in this mode; stock-OS loading, physical-machine timing, interrupt coexistence and tear-free animation need their own checks.

Draw bitmap text reuses these spans for glyphs at arbitrary pixel positions, with an explicit font, advance and scissor.