Charset ASM Example

Displaying a Custom Font on Real Z80 Hardware
by Norbert of Norbsoft

The Charset Editor lets you redesign the ZX Spectrum's 96-character font (ASCII 32-127) and export it as a raw .chr file — 8 bytes per character, 768 bytes in total. charset_viewer.asm is a minimal companion program that incbins that exported file and stamps every character of the new font directly onto the screen, writing pixel bytes directly into screen memory — no ROM calls, so it works identically on a 48K or 128K machine regardless of which ROM is paged in.

This example is written for Pasmo with the --tapbas flag, producing a .tap file that auto-runs in an emulator such as Fuse with --auto-load — exactly the workflow described in the Z80 Assembly Introduction.

Charset: Displaying an Exported Font

[Back to top]

Unlike the UDG and Player Sprite editors, the Charset Editor's most useful export format is .chr — a compact binary blob with no header, just 768 raw pixel bytes (96 characters x 8 bytes each), in ASCII order starting at character 32 (space). The viewer pulls this data in with incbin, which embeds the file's bytes verbatim at assembly time.

Loading the Font Data

The 768 bytes from the .chr file are first copied into a RAM buffer with ldir. This isn't strictly necessary for this demo — the viewer could read straight from the incbin'd data — but it mirrors how a real program would work: copying the font into RAM is the first step toward pointing the ZX Spectrum's CHARS system variable (23606/23607) at it, so your custom font replaces the system font for every subsequent print.

; Copy .chr data into RAM buffer
        ld      hl, chr_data
        ld      de, chr_buf
        ld      bc, 768         ; 96 chars x 8 bytes
        ldir

Clearing the Screen

Before drawing, the viewer blanks the entire 6144-byte pixel display file ($4000-$57FF) and sets every attribute cell to $38PAPER 7 (white), INK 0 (black) — so the new font's glyphs show up as black-on-white regardless of whatever was on screen before:

; Clear pixel memory ($4000-$57FF = 6144 bytes)
        ld      hl, $4000
        ld      de, $4001
        ld      bc, $17FF
        ld      (hl), 0
        ldir

; Set all attributes to PAPER 7 (white), INK 0 (black) = $38
        ld      hl, $5800
        ld      de, $5801
        ld      bc, $02FF
        ld      (hl), $38
        ldir

The Draw Loop

The 95 visible characters (ASCII 32-126) are drawn in reading order, filling 32 columns per row. At 95 characters that's 3 character rows (32 + 32 + 31) — small enough to fit entirely within screen "third" 0 ($4000-$47FF, character rows 0-7), so the address arithmetic is simple: for character row D, column E and scan line Y (0-7), the screen address is $4000 + Y*256 + D*32 + E.

ld      ix, chr_buf     ; IX -> font data for current character
        ld      d, 0            ; D = character row (0, 1, 2)
        ld      e, 0            ; E = character column (0-31)
        ld      b, 95           ; 95 characters to draw

draw_loop:
        push    bc

        ; Low byte of screen address for this cell = D*32 + E
        ld      a, d
        add     a, a
        add     a, a
        add     a, a
        add     a, a
        add     a, a            ; A = D * 32
        add     a, e
        ld      c, a            ; C = base low byte (same for all 8 scan lines)

        ; Stamp all 8 scan lines of this character
        ld      b, 8
        ld      h, $40          ; high byte = $40 + scan line y
draw_scan:
        ld      l, c            ; low byte = D*32 + E
        ld      a, (ix)         ; font byte
        ld      (hl), a         ; write pixel row to screen
        inc     ix
        inc     h               ; advance to next scan line
        djnz    draw_scan

        ; Move to next character position; wrap column at 32
        inc     e
        ld      a, e
        cp      32
        jr      nz, next_char
        ld      e, 0
        inc     d
next_char:
        pop     bc
        djnz    draw_loop

Because each character is exactly 8 bytes wide in the .chr file and characters are stored in ASCII order from 32 upwards, IX simply walks straight through chr_buf one byte at a time — the 95th character drawn (ASCII 126, ~) is the 95th 8-byte block in the file.

Border Flags and the Halt Loop

As with the other examples, two out (254), a calls bracket the work: the border turns red the instant the program starts, and white once the whole font has been drawn. The program then idles forever in a halt / jr halt_loop pair.

Source: charset_viewer.asm

This is the complete viewer. The incbin "myfont.chr" line at the bottom names the exported .chr file it expects to find alongside it.

You will need to rename myfont.chr in the incbin line to match whatever filename your own exported charset was saved as — and remember, this example only works with a CHR export from the Charset Editor (the binary 768-byte font), not an ASM export.

; charset_viewer.asm
;
; Displays a custom ZX Spectrum character set from a .chr file.
; Characters are written directly to screen memory — no ROM calls,
; works on 48K or 128K regardless of which ROM is active.
;
; Assemble:  pasmo --tapbas charset_viewer.asm charset_viewer.tap
; Run:       fuse --auto-load charset_viewer.tap

        org     $8000

start:
        ; Red border = code is running
        ld      a, 2
        out     (254), a

        ; Copy .chr data into RAM buffer
        ld      hl, chr_data
        ld      de, chr_buf
        ld      bc, 768         ; 96 chars x 8 bytes
        ldir

        ; Clear pixel memory ($4000-$57FF = 6144 bytes)
        ld      hl, $4000
        ld      de, $4001
        ld      bc, $17FF
        ld      (hl), 0
        ldir

        ; Set all attributes to PAPER 7 (white), INK 0 (black) = $38
        ld      hl, $5800
        ld      de, $5801
        ld      bc, $02FF
        ld      (hl), $38
        ldir

        ; Draw 95 characters (ASCII 32-126) directly to screen memory.
        ;
        ; The Spectrum screen layout for character row R (0-7), column C (0-31),
        ; scan line Y (0-7) within the first screen third:
        ;   address = $4000 + Y*256 + R*32 + C
        ;
        ; Our 95 chars span rows 0-2 (32+32+31), all within the first third,
        ; so this simplified formula applies throughout.

        ld      ix, chr_buf     ; IX -> font data for current character
        ld      d, 0            ; D = character row (0, 1, 2)
        ld      e, 0            ; E = character column (0-31)
        ld      b, 95           ; 95 characters to draw

draw_loop:
        push    bc

        ; Low byte of screen address for this cell = D*32 + E
        ld      a, d
        add     a, a
        add     a, a
        add     a, a
        add     a, a
        add     a, a            ; A = D * 32
        add     a, e
        ld      c, a            ; C = base low byte (same for all 8 scan lines)

        ; Stamp all 8 scan lines of this character
        ld      b, 8
        ld      h, $40          ; high byte = $40 + scan line y
draw_scan:
        ld      l, c            ; low byte = D*32 + E
        ld      a, (ix)         ; font byte
        ld      (hl), a         ; write pixel row to screen
        inc     ix
        inc     h               ; advance to next scan line
        djnz    draw_scan

        ; Move to next character position; wrap column at 32
        inc     e
        ld      a, e
        cp      32
        jr      nz, next_char
        ld      e, 0
        inc     d
next_char:
        pop     bc
        djnz    draw_loop

        ; White border = display ready
        ld      a, 7
        out     (254), a

halt_loop:
        halt
        jr      halt_loop

; ── data ─────────────────────────────────────────────────────────────────────

chr_buf:
        defs    768             ; RAM workspace filled at startup by ldir

chr_data:
        incbin  "myfont.chr"    ; rename to match your exported .chr filename

        end     start

Assembling and Running

To try this with your own font: in the Charset Editor, design your characters and use Export CHR — this example only works with the binary .chr export, not the ASM export, since charset_viewer.asm uses incbin to pull in raw bytes directly. Save the .chr file in the same folder as charset_viewer.asm and edit the incbin line — currently incbin "myfont.chr" — to match whatever filename your export was saved as. Then assemble and run it exactly as described in the Z80 Assembly Introduction:

pasmo --tapbas charset_viewer.asm charset_viewer.tap && fuse --tape charset_viewer.tap --auto-load

The --tapbas flag wraps the assembled machine code in a small BASIC loader so the resulting .tap auto-runs when loaded; --auto-load tells Fuse to insert and run the tape image immediately on startup. You should see the border flash red, your custom font fill the top three rows of a white screen, and the border settle to white once drawing is complete.