Skip to content

Change Shared Character Pictures on C64

Reusable install, replace and select routines for C64, with real code, native output and explicit video ownership.

A single door picture can appear in many places. Replacing it changes every cell using its number. Changing one cell’s number affects only that instance. Here is reusable code for both jobs.

Native C64 output with two cells showing the same replacement picture
One replacement changes both cells. Later checks select a different picture in only one cell. Original Code198x patterns, captured from this machine's separately executed caller.

Include, initialise and call

Reserve 24 private zero-page bytes at G. Clear decimal mode, include the machine’s component and establish the caller-owned video configuration before calling glyph_init.

Include the maintained component

demo.asm, excerpt
kernel_start:
    !source "glyphs.inc"

Init clears the owned patterns and names. It destroys their old contents; call it during your setup, not before every tile change. The complete caller establishes the video state. Then install two original sketches and put the first in two cells:

Initialise, install pictures, then select the first twice

demo.asm, excerpt
    jsr glyph_init
example_install:
    lda #<original_patterns
    sta G
    lda #>original_patterns
    sta G+1
    lda #1
    sta G+2
    lda #2
    sta G+3
    jsr glyph_install
    lda #1
    sta G+4
    lda #<82
    sta G+5
    lda #>82
    sta G+6
    jsr glyph_select
    inc G+5
    jsr glyph_select

To replace both instances, point the source at a new sketch, set first=1 and call glyph_replace. To change only one cell, select code 2 there. Games call these entries directly; the later mailbox and readback buffer are optional verification transport.

The calling contract

Words are little-endian. glyph_install uploads a range, glyph_replace uploads one picture and ignores count, glyph_select changes one name. Status and data-write count reset on every call, including rejection.

Offset Supply or read
0 CPU source address, word
2, 3 First slot and nonzero count, bytes
4 Selected code, byte
5 Linear cell, word
7 Output: 1 accepted, 0 rejected
8 Output: destination bytes written, word
10–23 Private scratch

Valid slots/codes are 0–255, cells 0–999. Counts are bytes, 1–255; an exclusive range end of 256 is legal. Zero counts, overflowing ranges and invalid cells reject without destination writes. Use multiple calls to install all 256 pictures.

Keep the source readable, stable, long enough, non-wrapping and separate from the context and destination. These are trusted pointers, not a memory sandbox. The context is non-re-entrant. Inputs 0–6 survive; A/X/Y and flags are clobbered. Reserve six stack bytes including the direct call. Add space for your own calls and interrupts.

Respect the display

The caller selects VIC bank 0 with CIA2, $D018=$18, $D011=$1B and $D016=$08. Pictures occupy $2000–$27FF, names $0400–$07E7. A character number is a screen code, not PETSCII. The component preserves the 24 padding bytes after the names and the separately owned colour RAM at $D800–$DBFF. Set high-resolution colours yourself; multicolour changes what the bits mean.

The original reset caller uses CPU mapping $37 and blank RAM. It supplies its own pictures; it does not copy a ROM font or keep BASIC/KERNAL running. The stock-ROM wrapper below keeps the standard letters while adding private pictures. Your project must reserve the same RAM and avoid another renderer, OS or interrupt using it.

Turn a sketch into stored pixels

Each picture has eight bytes, one row per byte, bit 7 on the left. Row 10000001 is 128+1 = 129. Slot n, row r lives at $2000+n*8+r. Slot 255 begins at $27F8 and ends at $27FF.

For cell (2,2), the index is 2*40+2 = 82. Dot (3,5) within it appears at screen pixel (2*8+3, 2*8+5) = (19,21). The colour byte belongs to that cell, while the shared picture belongs to its slot.

Move a glyph connects these bit weights and cell indices with text drawing. Place a point covers local-to-screen coordinates. These routines place fixed-grid pictures; they do not provide proportional text or arbitrary-size letters.

Read the complete reusable 6502 component

Initialise, install, replace and select

glyphs.inc
; SPDX-License-Identifier: MIT
; C64 standard hi-res character RAM component. G = 24 private zero-page bytes.
; +0 CPU source word; +2 first byte; +3 count byte (1..255); +4 code byte;
; +5 cell word; +7 status; +8 data-write count word; +10..23 scratch. LE words.
; Inputs 0..6 preserved. A/X/Y/flags clobbered, decimal clear; non-reentrant.
; Source must remain readable without wrapping/overlap. No bank/IRQ changes.
; Caller owns VIC bank, character/screen bases, colour RAM and CPU mapping.
glyph_init:
    jsr glyph_reset_result
    lda #<8192
    sta G+12
    lda #>8192
    sta G+13
    lda #<2048
    sta G+16
    lda #>2048
    sta G+17
    jsr glyph_zero_range
    lda #<1024
    sta G+12
    lda #>1024
    sta G+13
    lda #<1000
    sta G+16
    lda #>1000
    sta G+17
    jsr glyph_zero_range
    jmp glyph_accept
glyph_zero_range:
    jsr glyph_address
zero_next:
    lda #0
    jsr glyph_store
    jsr glyph_decrement
    bne zero_next
    rts
glyph_replace:
    lda #1
    bne glyph_begin
glyph_install:
    lda G+3
glyph_begin:
    sta G+10
    jsr glyph_reset_result
    lda G+10
    beq glyph_reject
    clc
    adc G+2
    bcc glyph_range_ok
    bne glyph_reject ; carry plus zero is the legal exclusive end 256
 glyph_range_ok:
    lda G+2
    asl
    sta G+12
    lda #0
    rol
    sta G+13
    asl G+12
    rol G+13
    asl G+12
    rol G+13
    lda G+13
    clc
    adc #>8192
    sta G+13
    jsr glyph_address
    lda G
    sta G+14
    lda G+1
    sta G+15
    lda G+10
    sta G+16
    lda #0
    sta G+17
    asl G+16
    rol G+17
    asl G+16
    rol G+17
    asl G+16
    rol G+17
    ldy #0
copy_next:
    lda (G+14),y
    jsr glyph_store
    inc G+14
    bne source_ok
    inc G+15
source_ok:
    jsr glyph_decrement
    bne copy_next
glyph_accept:
    lda #1
    sta G+7
glyph_reject:
    rts
glyph_select:
    jsr glyph_reset_result
    lda G+6
    cmp #>1000
    bcc cell_ok
    bne glyph_reject
    lda G+5
    cmp #<1000
    bcs glyph_reject
cell_ok:
    lda G+5
    sta G+12
    lda G+6
    clc
    adc #>1024
    sta G+13
    jsr glyph_address
    lda G+4
    jsr glyph_store
    jmp glyph_accept
glyph_reset_result:
    lda #0
    sta G+7
    sta G+8
    sta G+9
    rts
glyph_decrement:
    lda G+16
    bne length_low
    dec G+17
length_low:
    dec G+16
    lda G+16
    ora G+17
    rts
glyph_address:
    rts
glyph_store:
    ldy #0
    sta (G+12),y
    inc G+12
    bne destination_ok
    inc G+13
destination_ok:
    inc G+8
    bne count_ok
    inc G+9
count_ok:
    rts

Pay for what changes

A single name costs 1 destination byte; replacing a picture costs 8. Full init writes 3,048 bytes. The assembled component takes 238 code bytes and 24 context bytes; each source picture takes 8 bytes. These exclude the reset caller, video setup and assets.

If many cells use one picture and all should change, a replacement can save writes. If one door should open, change its name. These are storage and data-write counts, not elapsed time. Colour updates are separate work. Measure inside your actual game before promising a frame rate. Upload queues, caches and alternate allocations are useful further components.

What was verified

20 calls completed per build with Asm198x 0.0.71 and Emu198x 0.39.0. Checks compare complete font, names, padding and colour RAM, guards, inputs, status and actual destination-write counts. They cover shared replacement, single-cell selection, last slots/cells, maximum uploads, zero/overflow requests and reset.

Selected whole native frames match a separate compositor. Completed wrong-slot and wrong-name builds fail both memory and visible-output comparisons. Retained checks independently rederive the expectations; corruption tests reject rehashed wrong memory and pictures. Colour RAM is read by CPU code into a private buffer; debugger reads intentionally show underlying RAM.

This is an original reset fixture, not proof of stock-OS loading, interrupt coexistence, hardware timing or tear-free live updates. Palette RGB values describe the pinned emulator release. Code and source pictures are MIT licensed.

Keep the standard letters and add your own pictures

You do not have to redraw an alphabet to add a door or a character. glyph_init_rom copies one 2 KiB half of the machine’s character ROM into the existing $2000 font allocation. Then use the same glyph_install, glyph_replace and glyph_select routines above. Unchanged letters retain their familiar shapes; every cell using a replaced slot changes together.

The new rom-demo.asm is a stock-ROM-loaded PRG, separate from the original reset-ROM fixture. Load it and RUN. The first Fire press and release installs an original sketch in slot 1 and selects it in cells 82 and 83. The next Fire press replaces that sketch in both cells; holding the button adds no further replacement. rom-custom.asm chooses the lowercase ROM half. This is a standalone takeover program and stays in its own loop.

The stock-ROM PRG includes and chosen half

rom-demo.asm
HALF=0
ROM_WRONG=0
!source "rom-caller.inc"

Include and call the ROM initializer

Define G as 24 private zero-page bytes, ROM_WRONG=0 for the working initializer, and include glyphs.inc followed by rom-init.inc. Keep the code, context and stack below $A000. Supply A=0 or 1 for the ROM half and X=your known $01 output-latch shadow. After the call, G+7 is accepted/rejected and G+8/9 is the destination-byte count, as with the other glyph routines. Inputs G through G+6 survive; A/X/Y change. The caller’s processor flags, including decimal and interrupt state, are restored.

The wrapper requires the three banking DDR bits to be outputs and KERNAL to be already banked out. It checks those conditions and that the shadow matches the port’s output bits before making any destination writes. It cannot discover an incorrect shadow for a pin configured as an input: keep the real latch shadow yourself. Reading $01 is not a substitute for that shadow.

Own NMI handling too. Disable CIA2 NMI sources and provide a safe RAM NMI vector and handler before calling. The handler must tolerate I/O being hidden. SEI only masks IRQ; it does not mask NMI. This wrapper keeps KERNAL out so the RAM vector stays visible. It is not an OS-friendly routine that can borrow arbitrary BASIC/KERNAL state and return safely.

The temporary mapping selects character ROM rather than the VIC/SID/CIA registers. The exact caller-supplied latch value is restored afterwards; $00 is untouched. The initializer writes 2,048 font bytes, without clearing the screen or colour RAM. Configure VIC bank 0 and the $2000 font as in the complete caller. See the Commodore Programmer’s Reference Guide, memory management for the bank-control port.

Copy a ROM half and restore the caller's mapping

rom-init.inc
; SPDX-License-Identifier: MIT
; Extend glyphs.inc, G=24 private ZP bytes, code/context below $a000.
; A=ROM half 0/1. X=caller's known $01 output-latch shadow (not a port read).
; Standard no-cartridge mapping; KERNAL already out, banking DDR bits outputs.
; Safe RAM NMI vector/handler and CIA2 NMI ownership are caller prerequisites.
; Preserves inputs G..G+6 and caller P, including I/D; A/X/Y clobbered.
; G+7 accepted, G+8/9 data writes. Leaves screen/colour RAM/DDR untouched.
glyph_init_rom:
    sta G+11
    stx G+10
    php
    sei
    cld
    jsr glyph_reset_result
    lda G+11
    cmp #2
    bcs rom_reject
    lda $00
    and #7
    cmp #7
    bne rom_reject
    lda G+10
    and #2
    bne rom_reject
    lda G+10
    eor $01
    and $00
    and #$3f
    bne rom_reject
    lda G+10
    and #$f8
!if ROM_WRONG {
    ora #5 ; Deliberate error: leave I/O visible instead of character ROM.
} else {
    ora #1
}
    sta $01
    lda #0
    sta G+12
    sta G+14
    sta G+16
    lda #$20
    sta G+13
    lda #8
    sta G+17
    lda G+11
    asl
    asl
    asl
    clc
    adc #$d0
    sta G+15
rom_copy:
    ldy #0
    lda (G+14),y
    jsr glyph_store
    inc G+14
    bne rom_source_ok
    inc G+15
rom_source_ok:
    jsr glyph_decrement
    bne rom_copy
    lda G+10
    sta $01
    lda #1
    sta G+7
rom_reject:
    plp
    rts
rom_init_end:

Why the screen must survive

The old blank-font initializer fills names with code zero. Doing that with a copied ROM font would fill the screen with @ characters. This variant deliberately leaves every name alone. The caller fills its screen with screen-code 32 spaces, adds its caption and later selects the two original sketches. Screen codes and PETSCII still have different jobs.

Common error: copying I/O instead of ROM

The wrong build changes one immediate mask from 1 to 5, leaving CHAREN set. The bounded copy completes, but reads I/O-register values rather than the alphabet. Its status and 2,048-byte write count alone cannot prove success. Comparing the full private native font with the selected ROM half catches the error; the native image shows damaged letters. The repaired PRG is byte-for-byte identical to the working PRG.

Restoring an all-RAM CPU mapping also hides I/O. The boundary fixture checks exact restoration, then explicitly selects its I/O-visible map before reading the next joystick handshake. Restoring ownership is not the same as choosing a mapping suitable for the caller’s next operation.

Complete input-driven caller with private screen ownership

rom-caller.inc
; Standalone stock-ROM PRG. Takes ownership; does not return to BASIC.
*=$0801
    !byte $0b,$08,$0a,$00,$9e
    !text "2064"
    !byte 0,0,0
*=$0810
G=$70
CPU_MAP=$35
start:
    sei
    cld
    lda #$7f
    sta $dc0d
    sta $dd0d
    lda $dc0d
    lda $dd0d
    lda #0
    sta $d01a
    sta $d015
    sta $d020
    sta $d021
    lda #<safe_interrupt
    sta $fffa
    sta $fffe
    lda #>safe_interrupt
    sta $fffb
    sta $ffff
    lda #CPU_MAP
    sta $01
    lda $dd02
    ora #3
    sta $dd02
    lda $dd00
    ora #3
    sta $dd00
    lda #$1b
    sta $d011
    lda #8
    sta $d016
    lda #$18
    sta $d018
    ldx #0
    lda #32
clear_screen:
    sta $0400,x
    sta $0500,x
    sta $0600,x
    sta $0700,x
    inx
    bne clear_screen
    lda #1
colours:
    sta $d800,x
    sta $d900,x
    sta $da00,x
    sta $db00,x
    inx
    bne colours
    lda #$a5
font_guards:
    sta $1ff0,x
    sta $2800,x
    inx
    cpx #16
    bne font_guards
    ldx #0
banner_copy:
    lda banner,x
    sta $0400,x
    inx
    cpx #13
    bne banner_copy
    lda #<patterns
    sta G
    lda #>patterns
    sta G+1
    lda #1
    sta G+2
    sta G+3
    sta G+4
    lda #82
    sta G+5
    lda #0
    sta G+6
    lda #HALF
    ldx #CPU_MAP
    jsr glyph_init_rom
    lda G+7
    sta init_status
    lda G+8
    sta init_writes
    lda G+9
    sta init_writes+1
    lda $01
    sta restored_port
    jsr joy_init
    lda #$a5
    sta ready
init_done:
    jsr joy_poll
    lda joy2
    and #16
    beq init_done
initial_release:
    jsr joy_poll
    lda joy2
    and #16
    bne initial_release
; The second caller and observer can stop here before any glyph is patched.
    jsr glyph_install
    jsr glyph_select
    inc G+5
    jsr glyph_select
    lda #0
    sta pressed_before
main_loop:
    jsr joy_poll
    lda joy2
    and #16
    beq no_fire
    lda pressed_before
    bne frame_done
    lda #<patterns+8
    sta G
    lda #>patterns+8
    sta G+1
    jsr glyph_replace
    inc changes
no_fire:
    lda joy2
    and #16
    sta pressed_before
frame_done:
    jmp main_loop
safe_interrupt:
    rti
ready: !byte 0
init_status: !byte 0
init_writes: !word 0
restored_port: !byte 0
pressed_before: !byte 0
changes: !byte 0
banner: !byte 18,15,13,32,6,15,14,20,32,11,5,16,20
patterns: !byte 129,66,36,24,24,36,66,129
          !byte 255,129,129,129,129,129,129,255
    !source "glyphs.inc"
    !source "rom-init.inc"
    !source "../../input/joystick-reading/joystick.inc"
program_end:

Customisation, cost and evidence

Choose ROM half 0 or 1; change the installed slot and eight source bytes to suit your picture. Reserve slots carefully: replacing a letter also changes every occurrence of that letter. Move the fixed RAM font allocation only by adapting the existing component and VIC configuration together. Proportional text, arbitrary positions, multicolour and a different VIC bank need different allocation/rendering choices.

The wrapper adds 102 code bytes, uses the existing 24-byte context and copies 2,048 bytes during setup. The complete stock-ROM PRG is 778 bytes including its two-byte load address. One later shared replacement still writes only eight bytes. This is startup work, not a claim that a whole font fits your VBlank budget. The wrapper needs eight stack bytes including its direct call and nested copy helper; allow more for the surrounding caller and any interrupts.

Five native runs on Emu198x 0.44.2 with Asm198x 0.0.72 verify stock-PRG identity before RUN, both ROM halves, untouched screen, complete private font comparison, shared install/replacement, held input, guards and native pixels. A separate six-case guest caller checks invalid half, banking DDR, shadow mismatch, KERNAL visibility, all-RAM restoration and the lowercase half, including preserved decimal/interrupt/carry flags. Nineteen corruption controls reject altered evidence, including a coupled wrong displayed glyph. Complete licensed ROM/font dumps remain private; the retained evidence records hashes and only the few glyphs visible in each capture. This does not establish arbitrary interrupt coexistence or physical-hardware timing.

Keep the standard caption and replace one shared sketch in both cells.
Keep the standard caption and replace one shared sketch in both cells.
The wrong bank copies I/O values. A successful write count cannot prove that the alphabet survived.
The wrong bank copies I/O values. A successful write count cannot prove that the alphabet survived.

Stock-ROM code and native evidence. Bit weights, eight-byte row storage and cell indexing connect this extension with Maths for Games.