Skip to content

Draw Pixels, Spans and Boxes on Electron

Reusable 6502 drawing for Electron MODE 4, with strict bounds, interleaved rows and explicit display ownership.

Draw a life bar, outline a room or inspect one dot before deciding where a bullet lands. These are six callable routines for a bitmap you own. Use a point when you need one pixel; use a span or box when that is the job, so the machine does not calculate every address again.

Native Acorn Electron MODE 4 output showing filled and outlined rectangles, horizontal spans and a vertical edge
Original caller captured unchanged in Emu198x 0.39.0. Complete bitmap memory and selected native frames are independently checked.

Include it, then draw a box

Reserve 48 bytes at BP, in zero page at $70. Define PIXEL_WIDTH/HEIGHT as 320/256, and PIXEL_BYTE_STEP as 8. Include 6502.inc once and retain the complete caller’s bitmap_lo/hi row tables. Code, stack, tables, BP and the bitmap must not alias. Scratch needs no initialisation; public request fields do.

After the named display setup, this is a normal direct call. The excerpt stops before the diagnostic marker/mailbox; your game does not need those stores.

Outline [13,91) across and [27,53) down

demo.asm, excerpt
; A normal game call: outline a box. End coordinates are exclusive.
    lda #<13
    sta BP+0
    lda #>13
    sta BP+1
    lda #<27
    sta BP+2
    lda #>27
    sta BP+3
    lda #<91
    sta BP+4
    lda #>91
    sta BP+5
    lda #<53
    sta BP+6
    lda #>53
    sta BP+7
    lda #0
    sta BP+8
    jsr bp_box

Read BP+9: one means accepted, zero rejected. Change the fields and call again. There is no retained drawing list or game loop inside the component.

The calling contract

Words use little-endian order. Origin is top left, Y downwards. Call bp_pixel, bp_read, bp_hspan, bp_vspan, bp_fill or bp_box.

BP offset Supply or read
0,2 Unsigned x0,y0 words
4,6 Exclusive x1,y1 words
8 Operation 0 set, 1 clear, 2 XOR; read requires 0
9 Accepted 1, rejected 0
10 Stored read bit 0/1; otherwise 0
11 Ignored and preserved
12–39,42–47 Private scratch
40 Output word: destination-byte stores

Pixel/read ignore both ends; hspan ignores y1; vspan ignores x1. Fill and box use [x0,x1)×[y0,y1). A box [5,13)×[20,24) is eight pixels wide and four high. The effective rectangle must be ordered, nonempty and inside 320×256; invalid requests reject before destination writes, with zero read/write outputs. Public inputs 0–8 and 11 survive.

This is strict rejection rather than clipping. Intersect with your display before calling if you want partially visible shapes. An outline visits a shared corner once, including a 1×1 box; XOR therefore leaves its corner visible instead of toggling it twice.

Eight addresses across, one address down

MODE 4 is 320×256 with two stored pixel states. The caller fixes the screen start at $5800 and owns the ULA, palette and $5800–$7FFF bitmap. Eight scanlines of a character-sized cell sit next to each other:

address = $5800 + (y div 8)*320 + (x div 8)*8 + (y mod 8)

At (13,20), that is $5A8C, mask $04. Moving to the next horizontal byte adds eight addresses; moving down one scanline adds one until the next cell row. Set PIXEL_BYTE_STEP=8 and include the 512-byte row tables.

An OS screen scroll can change the start address. These tables describe the fixed reset display; they must be rebuilt or replaced if your display mapping changes. This uses original OS and zero-filled BASIC images, not Acorn firmware. A source shared with BBC-style addressing is useful reuse, but Electron timing, contention and palette behaviour still need their own evidence.

Division chooses a byte; remainder chooses a bit

At X13, 13 div 8 = 1 picks byte-column one and 13 mod 8 = 5 picks its sixth pixel. With bit 7 leftmost, 128 >> 5 = 4. OR sets the mask, AND with its inverse clears it, and XOR toggles it. Replacing the byte with just $04 would erase seven neighbours.

Place a point explains the origin and local/screen distinction. Draw with integer error shows how points and spans build curves. A qualified primitive does not automatically qualify those higher-level adapters on this display; their machine entries name their own evidence.

Open the reusable 6502 backend

Include the maintained backend once

6502.inc
; SPDX-License-Identifier: MIT
; Monochrome byte/span drawing with caller-supplied trusted scanline tables. BP reserves 48 zero-page bytes; decimal mode must be off.
; Public words: +0 x0, +2 y0, +4 x1, +6 y1 (little-endian, exclusive ends).
; +8 operation 0=set,1=clear,2=XOR. +9 accepted, +10 read value.
; +40 word counts destination-byte writes for the most recent call.
; Calls destroy A/X/Y/flags, +12..47; inputs +0..8 survive. Not re-entrant.
; bitmap_lo/hi[y] names the selected content viewport; PIXEL_BYTE_STEP is 1 (linear) or 8 (interleaved).
; All entries reject the whole request before writing; empty/reversed is rejected.
bp_fill:
    lda #0
    beq bp_enter
bp_box:
    lda #1
    bne bp_enter
bp_pixel:
    lda #2
    bne bp_enter
bp_read:
    lda #3
    bne bp_enter
bp_hspan:
    lda #4
    bne bp_enter
bp_vspan:
    lda #5
bp_enter:
    sta BP+12
    lda #0
    sta BP+9
    sta BP+10
    sta BP+40
    sta BP+41
    ldx #7
bp_copy:
    lda BP,x
    sta BP+16,x
    dex
    bpl bp_copy
    lda BP+12
    cmp #2
    bcc bp_validate
    cmp #5
    beq bp_unit_x
    ; Pixel/read/hspan have height 1.
    lda BP+18
    clc
    adc #1
    sta BP+22
    lda BP+19
    adc #0
    sta BP+23
    lda BP+12
    cmp #4
    beq bp_validate
bp_unit_x:
    lda BP+16
    clc
    adc #1
    sta BP+20
    lda BP+17
    adc #0
    sta BP+21
bp_validate:
    lda BP+12
    cmp #3
    bne bp_operation_check
    lda BP+8
    beq bp_operation_check
    rts
bp_operation_check:
    lda BP+8
    cmp #3
    bcs bp_invalid
    ; Checked coordinates use PIXEL_WIDTH/HEIGHT, not an assumed OS mode.
    lda BP+17
    cmp BP+21
    bcc bp_x_ordered
    bne bp_invalid
    lda BP+16
    cmp BP+20
    bcs bp_invalid
bp_x_ordered:
    lda BP+21
    cmp #>PIXEL_WIDTH
    bcc bp_y_check
    bne bp_invalid
    lda BP+20
    cmp #<PIXEL_WIDTH
    bcc bp_y_check
    bne bp_invalid
bp_y_check:
    lda BP+19
    cmp BP+23
    bcc bp_y_ordered
    bne bp_invalid
    lda BP+18
    cmp BP+22
    bcs bp_invalid
bp_y_ordered:
    lda BP+23
    cmp #>PIXEL_HEIGHT
    bcc bp_valid
    bne bp_invalid
    lda BP+22
    cmp #<PIXEL_HEIGHT
    bcc bp_valid
    beq bp_valid
bp_invalid:
    rts
bp_valid:
    lda #1
    sta BP+9
    lda BP+16
    and #7
    tax
    lda bp_right_masks,x
    sta BP+24
    lda bp_bit_masks,x
    sta BP+26
    lda BP+20
    and #7
    tax
    lda bp_left_masks,x
    sta BP+25
    ; last pixel phase = (x1-1)&7.
    txa
    sec
    sbc #1
    and #7
    tax
    lda bp_bit_masks,x
    sta BP+27
    ; First and last byte column, allowing x >= 256.
    lda BP+17
    sta BP+31
    lda BP+16
    lsr BP+31
    ror
    lsr BP+31
    ror
    lsr BP+31
    ror
    sta BP+28
    lda BP+20
    sec
    sbc #1
    sta BP+30
    lda BP+21
    sbc #0
    sta BP+31
    lsr BP+31
    ror BP+30
    lsr BP+31
    ror BP+30
    lsr BP+31
    ror BP+30
    lda BP+30
    sta BP+29
    lda BP+18
    sta BP+32
    lda BP+19
    sta BP+33
    lda BP+22
    sec
    sbc #1
    sta BP+42
    lda BP+23
    sbc #0
    sta BP+43
bp_row:
    lda #0
    sta BP+39
    ldx BP+32
    lda bitmap_lo,x
    sta BP+34
    lda bitmap_hi,x
    sta BP+35
    ; Add byte column to the configured row start.
    lda BP+28
    ldx #0
    stx BP+31
!if PIXEL_BYTE_STEP = 8 {
    asl
    rol BP+31
    asl
    rol BP+31
    asl
    rol BP+31
}
    clc
    adc BP+34
    sta BP+34
    lda BP+31
    adc BP+35
    sta BP+35
    lda BP+28
    sta BP+30
    lda BP+24
    sta BP+36
    lda BP+25
    sta BP+37
    lda BP+12
    cmp #1
    bne bp_bytes
    ; Middle rows of an outline contain only its left and right pixels.
    lda BP+32
    cmp BP+18
    beq bp_bytes
    cmp BP+42
    bne bp_sides
    lda BP+33
    cmp BP+43
    beq bp_bytes
bp_sides:
    lda #1
    sta BP+39
    lda BP+26
    sta BP+36
    lda BP+27
    sta BP+37
bp_bytes:
    lda #255
    sta BP+38
    lda BP+30
    cmp BP+28
    bne bp_not_first
    lda BP+36
    sta BP+38
bp_not_first:
    lda BP+30
    cmp BP+29
    bne bp_not_last
    lda BP+38
    and BP+37
    sta BP+38
    ; Same-byte outline sides need OR, not intersection.
    lda BP+39
    beq bp_not_last
    lda BP+30
    cmp BP+28
    bne bp_not_last
    lda BP+36
    ora BP+37
    sta BP+38
bp_not_last:
    ; For outline middle rows, skip bytes between the sides.
    lda BP+39
    beq bp_apply
    lda BP+30
    cmp BP+28
    beq bp_apply
    cmp BP+29
    bne bp_advance
bp_apply:
    ldy #0
    lda BP+12
    cmp #3
    bne bp_write
    lda (BP+34),y
    and BP+38
    beq bp_return
    lda #1
    sta BP+10
bp_return:
    rts
bp_write:
    lda BP+8
    beq bp_set
    cmp #1
    beq bp_clear
    lda (BP+34),y
    eor BP+38
    jmp bp_store
bp_clear:
    lda BP+38
    eor #255
    and (BP+34),y
    jmp bp_store
bp_set:
    lda BP+38
    ora (BP+34),y
bp_store:
    sta (BP+34),y
    inc BP+40
    bne bp_advance
    inc BP+41
bp_advance:
    lda BP+30
    cmp BP+29
    beq bp_next_row
    inc BP+30
    clc
    lda BP+34
    adc #PIXEL_BYTE_STEP
    sta BP+34
    bcc bp_bytes_jump
    inc BP+35
bp_bytes_jump:
    jmp bp_bytes
bp_next_row:
    inc BP+32
    bne bp_row_test
    inc BP+33
bp_row_test:
    lda BP+32
    cmp BP+22
    bne bp_more_rows
    lda BP+33
    cmp BP+23
    beq bp_return
bp_more_rows:
    jmp bp_row
bp_right_masks:
    !byte 255,127,63,31,15,7,3,1
bp_left_masks:
    !byte 255,128,192,224,240,248,252,254
bp_bit_masks:
    !byte 128,64,32,16,8,4,2,1

What it costs, and what to try next

A 32×8 fill takes 32 byte stores at X24 and 40 at X25 on this backend. Full-display fills take 10,240 stores. Spans protect their two edges and write full interior bytes, avoiding repeated per-pixel work. These are observed destination stores, not elapsed cycles or a frame-budget promise.

The backend assembles to 537 bytes, plus 512 bytes of row tables and the 48-byte context. Reset setup, bitmap, stack and assets are additional. The row tables spend storage to avoid deriving the complete display address during every call. A character-backed or sprite display may fit a game’s actual job better than painting bitmap shapes.

Clobbers A/X/Y/P. Decimal mode must be off on entry; the caller uses CLD during setup. Reserve a conservative 64-byte stack budget beyond your other calls/IRQs. BP is non-re-entrant. The routine changes no interrupt enables, acquires no OS display and synchronises no video access. Your caller owns that integration.

What was verified

173 calls completed using Asm198x 0.0.71 and native Emu198x 0.39.0 in the stated original reset configuration. Every call compares the complete bitmap allocation, guards, public inputs, acceptance/read and byte-store counts with an independent selected-pixel model. Cases cover every horizontal phase, set/clear/XOR/read, tiny and full rectangles, last pixels, row boundaries, rejected declarations and aligned/unaligned fills. Selected whole native frames match independently composed images.

This machine ran independently; a related display’s result is not borrowed as proof. Wrong-mask and wrong-row executables each complete accepted calls and fail both memory and picture checks. Retained records bind exact source/tools and have evidence-corruption failure tests. Stock-OS loading, original-hardware timing and tear-free coexistence remain separate checks.