Skip to content

Place characters in a ZX81 display file

Draw counted labels and character pictures with the machine's ROM, with explicit bounds and display ownership.

A label, a room wall, a face made from several characters: a fixed font can do useful work. Give this routine a row of native screen codes and a cell position. It checks the request, then replaces that span. Your game owns the choice of pictures; the ROM supplies their dots.

Native ZX81 output from the reusable character-span caller.
Code198x native capture: 32×24 physical character positions, ROM-serviced refresh. The stock PAL 8 KiB ROM runs the BASIC USR caller with 16 KiB RAM. Qualification places the image at $6100 using debugger writes. Every pixel of the first 22 rows is compared; the bottom two remain available to input. This is not a cassette loader or a 1 KiB footprint claim. Character designs belong to the machine's manufacturer; no firmware is distributed.

Include, set up, call

Copy characters.inc into your project and include it once. Reserve 24 private bytes at FC. Also define COLS=32, ROWS=22 and DF_END=$4010 as in the caller. The first maintained call places a six-character span; the full caller below shows allocations and inclusion:

Complete ZX81 caller

demo.asm, excerpt
case_0:
    ld hl,pictures
    ld (FC),hl
    ld a,2
    ld (FC+2),a
    ld a,0
    ld (FC+3),a
    ld a,6
    ld (FC+4),a
    ld a,0
    ld (FC+7),a
    call character_span
    ld a,(FC+5)
    or 128
    ld ($6020),a

Stock-ROM USR bridge

caller.bas
10 CLS
20 PRINT "          "
30 PRINT "          "
40 PRINT "          "
50 LET A=USR 24832
60 INPUT A

Put a source pointer at FC+0..1 (low byte first), column at +2, row at +3 and nonzero count at +4. Offset +7 is unused on this machine. Call character_span. Byte +5 returns 1 when accepted, 0 when rejected; +6 returns the number of character writes, or 0. Inputs +0..4 and +7 survive. Scratch/status +5..23 belong to the routine.

A request stays on one row. Zero counts, invalid starts and horizontal overruns reject before any display write. Sinclair requests also reject unallocated cells or unsafe source codes. This variant rejects rather than clips: shorten a label deliberately if that is the behaviour you want.

Keep the source long enough, readable, stable, non-wrapping and separate from anything the call can overwrite. Contexts are not re-entrant. AF/BC/DE/HL and flags are clobbered; IX/IY survive. Reserve four stack bytes for the direct call and its helpers, plus your caller and interrupts. The USR bridge adds eight saved-register bytes and returns BC=0 to BASIC. The routine changes no interrupt state or display mode.

The USR bridge assumes the assembled image is already at $6100. Reserve the context/results area at $6000 and the image through $63FF against BASIC growth before adapting it to a larger program. This small verification caller stays below that region; it does not install a RAMTOP policy or a tape loader for you.

Use the actual display

The ZX81 keeps a moving, newline-terminated display file. Its current address is held at $400C. VARS at $4010 supplies the following allocation boundary; that address means something different from ZX80 DF-END.

A is screen code 38. Codes 0–63 and inverse codes 128–191 are safe character names for this routine. Codes with bit 6 set reject before any write. In particular, 118 is a row terminator/HALT, not a drawable letter.

With the selected 16 KiB configuration the caller has full rows. It can replace a 32-cell row and the final cell of its owned area. The routine still walks row terminators rather than assuming that every future game has the same layout.

The caller uses 22 upper rows as a policy to leave input space; the display hardware has 24. A game that owns the entire valid display file can choose a different row limit. The include reads D_FILE afresh, walks to the row, preflights the whole span and then writes. PRINT, INPUT and editing can relocate or rebuild that file, so cached pointers need a clear lifetime.

The ZX80 normally blanks while computing and refreshes when the ROM waits for input. The ZX81 adds SLOW mode, which shares CPU time with display work; FAST suppresses that work. This component leaves that machinery alone. A standalone assembly game must provide its own refresh policy rather than assuming a free-running bitmap controller.

Connect cells, shapes and maths

At an 8×8 character grid, cell (2,3) begins at active pixel (16,24). That multiplication places a whole glyph; it does not let you change one of its dots. Border coordinates are a separate matter.

Block characters can supply a coarser drawing grid. With a genuine 2×2 block repertoire, coarse point (5,7) belongs to cell (2,3), quadrant (1,1): divide by two for the cell and take the remainders for the quadrant. You still need a lookup from the desired block mask to this ROM’s actual codes. This routine accepts those codes; it does not invent a universal semigraphics table.

Place a point develops local/grid coordinates. Move a glyph connects codes and stored shapes. A fixed font makes the distinction especially clear: a code can select a picture without giving the CPU writable picture memory. Use larger cell pictures or an approximation when the ROM lacks the shape; a high-resolution extension needs its own named configuration.

Read the reusable component and complete caller

Reusable character-span routine

characters.inc
; Define FC (24 private bytes). Source and request must not alias output.
; +0 source word, +2 x, +3 y, +4 count, +5 accepted, +6 writes,
; +7 Aquarius colour (ignored on Sinclair). Inputs +0..4 and +7 survive.
; AF/BC/DE/HL clobbered; IX/IY and interrupt state unchanged.
character_span:
    xor a
    ld (FC+5),a
    ld (FC+6),a
    ld a,(FC+2)
    cp COLS
    ret nc
    ld b,a
    ld a,COLS
    sub b
    ld b,a
    ld a,(FC+4)
    or a
    ret z
    cp b
    jr c,fc_count_fit
    ret nz
fc_count_fit:
    ld a,(FC+3)
    cp ROWS
    ret nc
; A current, ROM-owned, well-formed display file. Never allocate or extend it.
    ld hl,($400c)
    ld de,(DF_END)
    ld (FC+12),de
    ld a,(hl)
    cp $76
    ret nz
    inc hl
    ld a,(FC+3)
    ld b,a
    or a
    jr z,fc_row_ready
fc_skip_row:
    ld c,33
fc_scan:
    call fc_inside
    ret nc
    ld a,(hl)
    inc hl
    cp $76
    jr z,fc_newline
    dec c
    jr nz,fc_scan
    ret
fc_newline:
    djnz fc_skip_row
fc_row_ready:
    ld a,(FC+2)
    ld b,a
    or a
    jr z,fc_start_ready
fc_skip_column:
    call fc_inside
    ret nc
    ld a,(hl)
    cp $76
    ret z
    inc hl
    djnz fc_skip_column
fc_start_ready:
    ld (FC+10),hl
    ld a,(FC+4)
    ld b,a
    ld de,(FC)
fc_preflight:
    call fc_inside
    ret nc
    ld a,(hl)
    cp $76
    ret z
    ld a,(de)
    and $40
    ret nz
    inc de
    inc hl
    djnz fc_preflight
    ld hl,(FC+10)
    ld de,(FC)
    ld a,(FC+4)
    ld b,a
fc_copy:
    ld a,(de)
    ld (hl),a
    inc de
    inc hl
    djnz fc_copy
    ld a,(FC+4)
    ld (FC+6),a
    ld a,1
    ld (FC+5),a
    ret
; Carry means HL is below the exclusive end. Does not clobber BC/DE/HL.
fc_inside:
    ld a,(FC+13)
    cp h
    jr nz,fc_high
    ld a,(FC+12)
    cp l
fc_high:
    jr z,fc_equal
    ccf
    ret
fc_equal:
    or a
    ret
character_span_end:

Complete caller and configuration

demo.asm
ORG $6100
FC EQU $6000
COLS EQU 32
ROWS EQU 22
DF_END EQU $4010
start:
    push af
    push bc
    push de
    push hl
case_0:
    ld hl,pictures
    ld (FC),hl
    ld a,2
    ld (FC+2),a
    ld a,0
    ld (FC+3),a
    ld a,6
    ld (FC+4),a
    ld a,0
    ld (FC+7),a
    call character_span
    ld a,(FC+5)
    or 128
    ld ($6020),a
case_1:
    ld hl,pictures
    ld (FC),hl
    ld a,0
    ld (FC+2),a
    ld a,1
    ld (FC+3),a
    ld a,32
    ld (FC+4),a
    ld a,0
    ld (FC+7),a
    call character_span
    ld a,(FC+5)
    or 128
    ld ($6021),a
case_2:
    ld hl,pictures
    ld (FC),hl
    ld a,31
    ld (FC+2),a
    ld a,2
    ld (FC+3),a
    ld a,1
    ld (FC+4),a
    ld a,0
    ld (FC+7),a
    call character_span
    ld a,(FC+5)
    or 128
    ld ($6022),a
case_3:
    ld hl,pictures
    ld (FC),hl
    ld a,0
    ld (FC+2),a
    ld a,21
    ld (FC+3),a
    ld a,1
    ld (FC+4),a
    ld a,0
    ld (FC+7),a
    call character_span
    ld a,(FC+5)
    or 128
    ld ($6023),a
case_4:
    ld hl,pictures
    ld (FC),hl
    ld a,32
    ld (FC+2),a
    ld a,0
    ld (FC+3),a
    ld a,1
    ld (FC+4),a
    ld a,0
    ld (FC+7),a
    call character_span
    ld a,(FC+5)
    or 128
    ld ($6024),a
case_5:
    ld hl,pictures
    ld (FC),hl
    ld a,0
    ld (FC+2),a
    ld a,22
    ld (FC+3),a
    ld a,1
    ld (FC+4),a
    ld a,0
    ld (FC+7),a
    call character_span
    ld a,(FC+5)
    or 128
    ld ($6025),a
case_6:
    ld hl,pictures
    ld (FC),hl
    ld a,0
    ld (FC+2),a
    ld a,0
    ld (FC+3),a
    ld a,0
    ld (FC+4),a
    ld a,0
    ld (FC+7),a
    call character_span
    ld a,(FC+5)
    or 128
    ld ($6026),a
case_7:
    ld hl,pictures
    ld (FC),hl
    ld a,31
    ld (FC+2),a
    ld a,0
    ld (FC+3),a
    ld a,2
    ld (FC+4),a
    ld a,0
    ld (FC+7),a
    call character_span
    ld a,(FC+5)
    or 128
    ld ($6027),a
case_8:
    ld hl,pictures
    ld (FC),hl
    ld a,255
    ld (FC+2),a
    ld a,0
    ld (FC+3),a
    ld a,2
    ld (FC+4),a
    ld a,0
    ld (FC+7),a
    call character_span
    ld a,(FC+5)
    or 128
    ld ($6028),a
case_9:
    ld hl,pictures
    ld (FC),hl
    ld a,0
    ld (FC+2),a
    ld a,255
    ld (FC+3),a
    ld a,1
    ld (FC+4),a
    ld a,0
    ld (FC+7),a
    call character_span
    ld a,(FC+5)
    or 128
    ld ($6029),a
case_10:
    ld hl,pictures
    ld (FC),hl
    ld a,0
    ld (FC+2),a
    ld a,0
    ld (FC+3),a
    ld a,255
    ld (FC+4),a
    ld a,0
    ld (FC+7),a
    call character_span
    ld a,(FC+5)
    or 128
    ld ($602a),a
case_11:
    ld hl,bad_picture
    ld (FC),hl
    ld a,0
    ld (FC+2),a
    ld a,2
    ld (FC+3),a
    ld a,2
    ld (FC+4),a
    ld a,0
    ld (FC+7),a
    call character_span
    ld a,(FC+5)
    or 128
    ld ($602b),a
case_12:
    ld hl,inverse_picture
    ld (FC),hl
    ld a,0
    ld (FC+2),a
    ld a,2
    ld (FC+3),a
    ld a,1
    ld (FC+4),a
    ld a,0
    ld (FC+7),a
    call character_span
    ld a,(FC+5)
    or 128
    ld ($602c),a
    ld a,239
    ld ($603f),a
    pop hl
    pop de
    pop bc
    pop af
    ld bc,0
    ret
    INCLUDE "characters.inc"
pictures:
    db 38,39,40,41,42,43,38,39,40,41,42,43,38,39,40,41,42,43,38,39,40,41,42,43,38,39,40,41,42,43,38,39,40,41,42,43,38,39,40,41,42,43
bad_picture:
    db 118,38
inverse_picture:
    db 166

Pay for the work

The component is 164 code bytes, excluding caller and source, with 24 reserved context bytes. A six-character label needs six source bytes and six destination writes. Sinclair also walks preceding rows and checks the target span before copying. Those are storage/work counts, not measured cycles or a promise to fit one frame.

PRINT is simpler when the ROM owns formatting, placement and scrolling. Direct spans suit a game that owns its cells. Cached row pointers or a trusted fast path can reduce work, but their validity depends on allocation lifetime and validated inputs. Clipping, transparent cells, proportional text and arbitrary-size lettering each change the contract.

What was verified

The stock PAL 8 KiB ROM runs the BASIC USR caller with 16 KiB RAM. Qualification places the image at $6100 using debugger writes. Every pixel of the first 22 rows is compared; the bottom two remain available to input. This is not a cassette loader or a 1 KiB footprint claim.

Three builds per machine complete every call, including boundary and rejection cases. Completed wrong-stride controls fail the same memory and visible-output checks. The second control bypasses source-token validation; the independent newline preflight still prevents a count-only overflow.

Native pixel comparisons cover the caller-owned rectangle, with one global alignment for native frame padding. They do not claim that moving input/cursor rows stay unchanged. Retained CI checks source/image/memory hashes, call markers and independent destinations. Fresh execution needs separately supplied firmware. These observations do not establish original-hardware timing, tear-free updates or OS coexistence.