Skip to content

Software Scrolling a Bitmap Band

Move a Spectrum playfield one pixel at a time, supplying the newly exposed edge yourself. Reusable Z80 code and a running tiled-band caller.

The Spectrum cannot move this bitmap by writing a fine-scroll register. This routine moves the pixels itself. A repeating grid slides through a 48-line band while the rest of the screen stays still.

A tiled grid scrolling through a narrow band on the Spectrum bitmap

Original Code198x program captured in Emu198x. The capture shows the small band used by the caller, not a full-screen performance claim.

Code status: maintained Z80 routine with independent bitmap checks, plus a ROM-loaded tape caller. Execution record. The scrolling family compares this with character shifts and hardware offsets on other machines.

Include it and supply an edge

Include scroll.inc and the maintained bitmap-addressing include once. Pass HL pointing to one byte per scanline; bit 0 of each byte is the pixel entering at X=255. D is the top pixel row and B is the height. For a non-empty band, top+height must be at most 192. A zero height is a no-op.

Z80 assembly
    ld hl,next_edge       ; at least 48 readable bytes, outside the bitmap
    ld d,72
    ld b,48
    call scroll_band_left

The routine moves the full 256-pixel width left by one pixel. HL advances by the height. AF, BC and DE change; IX/IY survive. It uses no static writable state and needs eight temporary stack bytes beyond the caller’s return address and any interrupt stack use. The caller prevents concurrent writes to the same band.

Shift a bitmap band and inject the new edge

scroll.inc
; SPDX-License-Identifier: MIT
; Input HL = one edge byte per scanline (only bit 0 is used), D=top y, B=height.
; Full 256-pixel width; top+height <=192. Height 0 is a no-op.
; Bitmap only: attributes do not move. Readable edge data must not overlap screen.
; Output HL advances by height. AF/BC/DE change; IX/IY preserved. No static state.
; Include bitmap-addressing.inc once. Stack: 8 temporary bytes plus CALL/IRQ needs.
scroll_band_left:
    ld a,b
    or a
    ret z
scroll_band_row:
    push bc
    push hl
    ld a,(hl)
    push af
    ld e,0
    call pixel_addr
    ld a,l
    or 31
    ld l,a
    pop af
    rra                  ; incoming edge pixel -> carry
    ld c,32
scroll_band_byte:
    rl (hl)              ; rightmost byte first: carry travels towards the left
    dec hl
    dec c
    jr nz,scroll_band_byte
    pop hl
    inc hl
    inc d
    pop bc
    djnz scroll_band_row
    ret

Why work from the right?

The incoming edge bit becomes bit 0 of the last byte. Rotating that byte left puts its old bit 7 into carry, ready to enter the byte immediately to its left. Continue towards the left edge and every pixel moves one place. Processing the bytes in the opposite order would propagate the wrong neighbour’s bit.

The addressing routine finds the next physical scanline despite the Spectrum’s interleaving. The scroll tests include character-row and screen-third boundaries, row 191, zero height and a full-height band; they compare all bitmap bytes and require an altered edge-carry implementation to fail.

A complete caller

The caller owns the bitmap, attributes, private stack and ROM IM1 frame wait. It generates a repeating tile edge, then calls the same routine. Reset to leave. make in the source folder creates build/demo.tap; use LOAD "" on a 48K Spectrum.

Complete tiled-band tape program

demo.asm
org $8000
start:
    di
    ld sp,$ff00
    ld hl,$4000
    ld de,$4001
    ld bc,6143
    ld (hl),0
    ldir
    ld hl,$5800
    ld de,$5801
    ld bc,767
    ld (hl),71
    ldir
    ; A patterned 48-line band, leaving the rest of the bitmap untouched.
    ld d,72
    ld b,48
paint_row:
    push bc
    ld e,0
    call pixel_addr
    ld b,32
paint_byte:
    ld a,d
    and 7
    jr z,paint_solid
    cp 7
    jr z,paint_solid
    ld a,$81
    jr paint_gap
paint_solid:
    ld a,$ff
paint_gap:
    ld (hl),a
    inc hl
    djnz paint_byte
    inc d
    pop bc
    djnz paint_row
    ld hl,$cafe
    ld (ready),hl
    im 1
    ei
main:
    halt
    ; The band is intentionally small. This is not a full-screen frame budget.
    ; Prepare a repeating tile edge, one bit for every scanline.
    ld hl,edge_bits
    ld b,48
prepare_edge:
    ld a,(updates)
    and 7
    jr z,edge_solid
    ld a,b
    and 7
    jr z,edge_solid
    cp 1
    jr z,edge_solid
    xor a
    jr edge_ready
edge_solid:
    ld a,1
edge_ready:
    ld (hl),a
    inc hl
    djnz prepare_edge
    ld hl,edge_bits
    ld d,72
    ld b,48
    call scroll_band_left
    ld a,(updates)
    inc a
    ld (updates),a
    jr main
ready: defw 0
updates: defb 0
edge_bits: defs 48,0
include "scroll.inc"
include "../bitmap-addressing/bitmap-addressing.inc"
end start

What it costs, and what comes next

Each call visits 32×height bitmap bytes, with a read/modify/write rotate for each. A short band is a deliberately smaller workload than a full screen. No measured whole-game frame budget or tear-free guarantee is supplied.

Attributes stay where they are. Uniform ink/paper suits this example; moving a multicolour tile world requires a separate attribute policy at the coarse boundary, or a different visual design. The routine also leaves map decoding, camera policy and sprite restoration to the caller.

Character-step scrolling trades smoothness for less work. Pre-shifted tile graphics trade storage for fewer runtime shifts. Unrolled drawing trades code space for speed. Compare those approaches in the shared family, and restore old software sprites before moving their background.