Skip to content

Change Shared Character Pictures on NES CHR-RAM

Reusable install, replace and select routines for NES CHR-RAM, 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 NES CHR-RAM 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:
    .include "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 #<66
    sta G+5
    lda #>66
    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–959. 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

This caller is NROM-256 with CHR-RAM, not a CHR-ROM cartridge. Its explicit iNES header declares zero CHR-ROM banks. The component owns background patterns at PPU $0000–$0FFF and names at $2000–$23BF. Attribute bytes at $23C0–$23FF, palettes, the other pattern bank and the other physical nametable remain separate.

Turn rendering off for the entire call. $2001 is write-only; the routine cannot read it to validate that policy. Own NMI/mapper state and the $2005/$2006 latch, use increment-one addressing and restore scroll/address/control before display resumes. A full upload does not fit by assumption into VBlank. This is a blanked-transfer component; a live queue would need its own budget and bank contract.

The pinned ca65 linker emits an extra empty CHARS segment. The builder verifies the header and exact allowed extents, rejects any nonempty undeclared data, then writes the declared 32,784-byte cartridge. Removing assets does not make a CHR-ROM cartridge writable.

Turn a sketch into stored pixels

Each tile has 16 bytes: eight low-plane rows followed by eight high-plane rows. For a dot in row r, combine bit b from offsets r and r+8 as low + 2*high. Bit 7 is the leftmost dot. Tile 255 begins at $0FF0 and ends at $0FFF.

Cell (2,2) has index 2*32+2 = 66. A tile chooses two-bit pixel values; attribute data chooses its background palette. Replacing tile data does not change those attributes. The caller makes all four background palettes equivalent so the checked attribute guards can vary without changing the example’s colours.

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
; NROM CHR-RAM background 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.
; RENDERING OFF is a caller precondition. PPUMASK is write-only; this routine does not pretend to read it. Own PPU address latch, increment=1 and restore scroll/address before display.
glyph_init:
    jsr glyph_reset_result
    lda #<0
    sta G+12
    lda #>0
    sta G+13
    lda #<4096
    sta G+16
    lda #>4096
    sta G+17
    jsr glyph_zero_range
    lda #<8192
    sta G+12
    lda #>8192
    sta G+13
    lda #<960
    sta G+16
    lda #>960
    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
    asl G+12
    rol G+13
    lda G+13
    clc
    adc #>0
    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
    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 #>960
    bcc cell_ok
    bne glyph_reject
    lda G+5
    cmp #<960
    bcs glyph_reject
cell_ok:
    lda G+5
    sta G+12
    lda G+6
    clc
    adc #>8192
    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:
    bit $2002
    lda G+13
    sta $2006
    lda G+12
    sta $2006
    rts
glyph_store:
    sta $2007
    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 16. Full init writes 5,056 bytes. The assembled component takes 258 code bytes and 24 context bytes; each source picture takes 16 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. PPU address writes also cost 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 logical PPU memory, 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. The diagnostic reads $3E00 through its physical $2E00 mirror to avoid palette reads bypassing the read buffer at $3F00.

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.