Skip to content

Clip Masked Sprites in BBC MODE4

Draw the visible part of a byte-aligned sprite in the normal MODE4 screen, preserving source stride and the BBC’s interleaved character-row layout.

Original bbc scene with masked objects cropped at the four screen corners and drawn inside the viewport

Original Code198x program and artwork captured in Emu198x.

Code status: maintained routines and an emulator-executed caller. Execution record.

MODE4 packs eight pixel rows into each character cell. Clipping a sprite therefore needs two pieces of care: selecting the surviving source pixels and finding the matching rows in that interleaved bitmap. This wrapper does both for a byte-aligned 16×16 object.

Give the wrapper its workspace

Include clip.inc and draw.inc once. Set clip_ptr ($70/$71) to 32 caller-owned bytes. Store signed X at +0, signed Y at +14 and source pointer at +28. X is measured in eight-pixel byte columns; the normal 320-pixel MODE4 screen has forty. Y is measured in pixel rows, from zero to 255 when visible.

Source data contains 64 interleaved keep-mask/image bytes, four bytes per original row. Keep-mask 1 preserves scenery; corresponding image bits must be zero. Both source and context must be separate from code, stack and the bitmap.

The wrapper owns zero-page $70..$78 and its context. A/X/Y/P change; decimal mode must be clear. Interrupt users must preserve that workspace. Each axis uses a 14-byte block: input position +0, extent +2, limit +4; outputs destination +6, source skip +8 and count +10. +12..13 is scratch, and context +30 is a row counter. The wrapper supplies its own fixed extents and viewport limits.

Crop and draw a 16×16 image in the MODE4 bitmap

draw.inc
; SPDX-License-Identifier: MIT
; draw_clipped: clip_ptr -> 32-byte context; +0 signed X in byte columns,
; +14 signed Y in pixel rows, +28 source pointer (64 interleaved KEEP/image
; bytes for a 16x16 sprite). Remaining context is work and clip outputs.
; MODE4 standard unscrolled $5800..$7fff. No attributes or OS/display setup.
; A/X/Y/P change. D=0. Own ZP $70..$78 and context, exclude interrupt users.
; This implementation pushes TWO source bytes per
; row, in addition to JSR returns and IRQ use. No static background storage.
source_ptr=$72
screen_ptr=$74
row_ptr=$76
mask_byte=$78
draw_clipped:
    ldy #2
    lda #2
    sta (clip_ptr),y
    iny
    lda #0
    sta (clip_ptr),y
    iny
    lda #40
    sta (clip_ptr),y
    iny
    lda #0
    sta (clip_ptr),y
    jsr clip_axis
    ldy #10
    lda (clip_ptr),y
    bne draw_clip_y
    rts
draw_clip_y:
    clc
    lda clip_ptr
    adc #14
    sta clip_ptr
    bcc draw_y_pointer
    inc clip_ptr+1
draw_y_pointer:
    ldy #2
    lda #16
    sta (clip_ptr),y
    iny
    lda #0
    sta (clip_ptr),y
    iny
    sta (clip_ptr),y
    iny
    lda #1
    sta (clip_ptr),y
    jsr clip_axis
    sec
    lda clip_ptr
    sbc #14
    sta clip_ptr
    bcs draw_context_restored
    dec clip_ptr+1
draw_context_restored:
    ldy #24
    lda (clip_ptr),y
    bne draw_visible
    rts
draw_visible:
    ldy #30
    sta (clip_ptr),y
    ; source = original + skipped_rows*4 + skipped_columns*2.
    ldy #22
    lda (clip_ptr),y
    asl
    asl
    tax
    ldy #8
    lda (clip_ptr),y
    asl
    sta mask_byte
    txa
    clc
    adc mask_byte
    ldy #28
    adc (clip_ptr),y
    sta source_ptr
    iny
    lda (clip_ptr),y
    adc #0
    sta source_ptr+1
    ; MODE4: base + (y/8)*320 + x*8 + (y mod 8).
    lda #0
    sta row_ptr+1
    ldy #20
    lda (clip_ptr),y
    and #7
    sta mask_byte
    ldy #6
    lda (clip_ptr),y
    asl
    rol row_ptr+1
    asl
    rol row_ptr+1
    asl
    rol row_ptr+1
    ora mask_byte
    sta row_ptr
    lda row_ptr+1
    clc
    adc #$58
    sta row_ptr+1
    ldy #20
    lda (clip_ptr),y
    lsr
    lsr
    lsr
    tax
    beq draw_clipped_row
draw_cell_row:
    clc
    lda row_ptr
    adc #64
    sta row_ptr
    lda row_ptr+1
    adc #1
    sta row_ptr+1
    dex
    bne draw_cell_row
draw_clipped_row:
    lda row_ptr
    sta screen_ptr
    lda row_ptr+1
    sta screen_ptr+1
    lda source_ptr+1
    pha
    lda source_ptr
    pha
    ldy #10
    lda (clip_ptr),y
    tax
draw_clipped_byte:
    ldy #0
    lda (source_ptr),y
    sta mask_byte
    lda (screen_ptr),y
    and mask_byte
    iny
    ora (source_ptr),y
    dey
    sta (screen_ptr),y
    clc
    lda source_ptr
    adc #2
    sta source_ptr
    bcc draw_source_ready
    inc source_ptr+1
draw_source_ready:
    clc
    lda screen_ptr
    adc #8
    sta screen_ptr
    bcc draw_screen_ready
    inc screen_ptr+1
draw_screen_ready:
    dex
    bne draw_clipped_byte
    pla
    sta source_ptr
    pla
    sta source_ptr+1
    ldy #30
    lda (clip_ptr),y
    sec
    sbc #1
    sta (clip_ptr),y
    bne draw_next_row
    rts
draw_next_row:
    clc
    lda source_ptr
    adc #4
    sta source_ptr
    bcc draw_source_row
    inc source_ptr+1
draw_source_row:
    inc row_ptr
    bne draw_row_carry_ready
    inc row_ptr+1
draw_row_carry_ready:
    lda row_ptr
    and #7
    bne draw_clipped_row
    clc
    lda row_ptr
    adc #$38
    sta row_ptr
    lda row_ptr+1
    adc #1
    sta row_ptr+1
    jmp draw_clipped_row

The address calculation uses forty character columns and 320 bytes per eight-row band. It handles the page carry when a sprite crosses a character-row boundary. It assumes the normal unscrolled $5800..$7fff mapping; a changed CRTC screen start needs a different address mapper. The BBC Microcomputer User Guide describes MODE4’s 320×256 display and forty-column text layout.

The axis helper can also serve other jobs within its contract: signed 16-bit position, extent 0..256 and viewport limit 1..256. It returns all-zero results when invisible and preserves the input fields. It does not validate values outside that contract.

Signed interval clipping with explicit carry and borrow

clip.inc
; SPDX-License-Identifier: MIT
; clip_ptr=$70/$71 -> 14 caller-owned bytes, little-endian words:
; +0 signed position, +2 extent 0..256, +4 limit 1..256.
; +6 destination, +8 source skip, +10 count; +12 scratch capacity.
; All three result words zero when empty. Inputs survive. A/X/Y/P change.
; NMOS 6502: D must be clear. Pointer and workspace exclusively owned;
; interrupt users must preserve them. No hardware access or static save slot.
clip_ptr=$70
clip_axis:
    lda #0
    ldy #6
clip_clear:
    sta (clip_ptr),y
    iny
    cpy #12
    bne clip_clear
    ldy #2
    lda (clip_ptr),y
    iny
    ora (clip_ptr),y
    bne clip_nonzero
    rts
clip_nonzero:
    ldy #1
    lda (clip_ptr),y
    bpl clip_positive
    ; Compute unsigned magnitude into skip (including $8000).
    ldy #0
    sec
    lda #0
    sbc (clip_ptr),y
    ldy #8
    sta (clip_ptr),y
    ldy #1
    lda #0
    sbc (clip_ptr),y
    ldy #9
    sta (clip_ptr),y
    ; count = extent - skip; <=0 means fully outside.
    ldy #2
    lda (clip_ptr),y
    ldy #8
    sec
    sbc (clip_ptr),y
    ldy #10
    sta (clip_ptr),y
    ldy #3
    lda (clip_ptr),y
    ldy #9
    sbc (clip_ptr),y
    ldy #11
    sta (clip_ptr),y
    bcc clip_empty
    dey
    ora (clip_ptr),y
    beq clip_empty
    jmp clip_capacity
clip_positive:
    ; Copy positive position to destination, extent to count.
    ldy #0
    lda (clip_ptr),y
    ldy #6
    sta (clip_ptr),y
    ldy #1
    lda (clip_ptr),y
    ldy #7
    sta (clip_ptr),y
    ldy #2
    lda (clip_ptr),y
    ldy #10
    sta (clip_ptr),y
    ldy #3
    lda (clip_ptr),y
    ldy #11
    sta (clip_ptr),y
clip_capacity:
    ; capacity = limit - destination; <=0 means fully outside.
    ldy #4
    lda (clip_ptr),y
    ldy #6
    sec
    sbc (clip_ptr),y
    ldy #12
    sta (clip_ptr),y
    ldy #5
    lda (clip_ptr),y
    ldy #7
    sbc (clip_ptr),y
    ldy #13
    sta (clip_ptr),y
    bcc clip_empty
    dey
    ora (clip_ptr),y
    beq clip_empty
    ; Compare count with capacity, high then low (unsigned).
    ldy #11
    lda (clip_ptr),y
    ldy #13
    cmp (clip_ptr),y
    bcc clip_return
    bne clip_trim
    ldy #10
    lda (clip_ptr),y
    ldy #12
    cmp (clip_ptr),y
    bcc clip_return
clip_trim:
    ldy #12
    lda (clip_ptr),y
    ldy #10
    sta (clip_ptr),y
    ldy #13
    lda (clip_ptr),y
    ldy #11
    sta (clip_ptr),y
clip_return:
    rts
clip_empty:
    lda #0
    ldy #6
clip_clear_empty:
    sta (clip_ptr),y
    iny
    cpy #12
    bne clip_clear_empty
    rts

Run the complete scene

Build with make ASM198X=/path/to/asm198x. Type the generated build/loader.bas into Model B MOS1.20/BASICII. It selects MODE4, reserves machine-code memory with HIMEM=&3000 and enters the caller. The caller owns that memory and the bitmap, keeps MOS IRQs active and waits through OSBYTE19 after drawing. Reset to leave.

Complete MODE4 caller with edge and corner crops

demo.asm
*=$3000
start:
    cld
    cli
    lda #0
    sta $74
    lda #$58
    sta $75
    ldy #0
paint:
    lda #$55
    sta ($74),y
    iny
    bne paint
    inc $75
    lda $75
    cmp #$80
    bne paint
    lda #<65535
    sta context
    lda #>65535
    sta context+1
    lda #<65528
    sta context+14
    lda #>65528
    sta context+15
    lda #<shape_a
    sta context+28
    lda #>shape_a
    sta context+29
    lda #<context
    sta clip_ptr
    lda #>context
    sta clip_ptr+1
    jsr draw_clipped
    lda #<39
    sta context
    lda #>31
    sta context+1
    lda #<65528
    sta context+14
    lda #>65528
    sta context+15
    lda #<shape_a
    sta context+28
    lda #>shape_a
    sta context+29
    lda #<context
    sta clip_ptr
    lda #>context
    sta clip_ptr+1
    jsr draw_clipped
    lda #<65535
    sta context
    lda #>65535
    sta context+1
    lda #<248
    sta context+14
    lda #>248
    sta context+15
    lda #<shape_a
    sta context+28
    lda #>shape_a
    sta context+29
    lda #<context
    sta clip_ptr
    lda #>context
    sta clip_ptr+1
    jsr draw_clipped
    lda #<39
    sta context
    lda #>31
    sta context+1
    lda #<248
    sta context+14
    lda #>248
    sta context+15
    lda #<shape_a
    sta context+28
    lda #>shape_a
    sta context+29
    lda #<context
    sta clip_ptr
    lda #>context
    sta clip_ptr+1
    jsr draw_clipped
    lda #<15
    sta context
    lda #>15
    sta context+1
    lda #<120
    sta context+14
    lda #>120
    sta context+15
    lda #<shape_a
    sta context+28
    lda #>shape_a
    sta context+29
    lda #<context
    sta clip_ptr
    lda #>context
    sta clip_ptr+1
    jsr draw_clipped
    lda #<0
    sta context
    lda #>0
    sta context+1
    lda #<80
    sta context+14
    lda #>80
    sta context+15
    lda #<shape_a
    sta context+28
    lda #>shape_a
    sta context+29
    lda #<context
    sta clip_ptr
    lda #>context
    sta clip_ptr+1
    jsr draw_clipped
    lda #<38
    sta context
    lda #>30
    sta context+1
    lda #<160
    sta context+14
    lda #>160
    sta context+15
    lda #<shape_a
    sta context+28
    lda #>shape_a
    sta context+29
    lda #<context
    sta clip_ptr
    lda #>context
    sta clip_ptr+1
    jsr draw_clipped
    lda #<65534
    sta context
    lda #>65534
    sta context+1
    lda #<40
    sta context+14
    lda #>32
    sta context+15
    lda #<shape_a
    sta context+28
    lda #>shape_a
    sta context+29
    lda #<context
    sta clip_ptr
    lda #>context
    sta clip_ptr+1
    jsr draw_clipped
    lda #<40
    sta context
    lda #>32
    sta context+1
    lda #<40
    sta context+14
    lda #>32
    sta context+15
    lda #<shape_a
    sta context+28
    lda #>shape_a
    sta context+29
    lda #<context
    sta clip_ptr
    lda #>context
    sta clip_ptr+1
    jsr draw_clipped
    lda #$ca
    sta ready
main:
    lda #19
    jsr $fff4
    inc updates
    jmp main
ready: !byte 0
updates: !byte 0
context: !fill 32,0
!source "clip.inc"
!source "draw.inc"
!source "shapes.inc"

Keep the old footprint when moving

The wrapper pushes two source-pointer bytes per drawn row, in addition to call returns and interrupt needs. It keeps no saved background. Use separate background buffers when objects move over scenery, remembering the old clipped dimensions as well as the old address.

The shared clipping entry explains source stride, invisible objects and restoration order. This implementation deliberately leaves out horizontal bit shifts, CRTC wraparound and automatic erasure. Those are useful further components, with their own costs and contracts.

For individual pixel X positions, select prepared source phases and keep the padded footprint and original source stride explicit.