Skip to content

Build an OAM Page and Share Sprite Dropout

Pack active NES objects into a complete OAM page, hide unused slots and rotate priorities when a row exceeds the hardware limit. Includes a running cartridge.

Twelve objects fit comfortably in the NES’s sixty-four OAM entries. Put them on the same scanline and some disappear: the PPU’s per-line limit is eight entries. This component packs a complete page and offers a simple way to share that dropout.

Code status: maintained 6502 builder, independently checked on the NTSC NES emulator, with a complete caller. Execution record. See the sprite family for hardware multiplexing, software drawing and other overload policies.

Supply the objects and an owned staging page

Include oam.inc once. Supply five 64-byte arrays: obj_x, obj_y, obj_tile, obj_attr, obj_active. Each active object here is one 8×8 hardware sprite. X is raw OAM X; Y is the raw stored value, so the displayed top is Y+1. Values 0–238 are supported; Y≥239 is omitted. Choose legal tile/palette values and clear reserved attribute bits.

Also supply object_count, first_object, oam_used, oam_status, objects_left, object_index and a page-aligned oam_buffer[256]. Keep that page, arrays, stack and state separate. Clear decimal mode. A/X/Y/flags change.

Pack, hide and optionally rotate the OAM candidates

oam.inc
; SPDX-License-Identifier: MIT
; Caller: arrays obj_{x,y,tile,attr,active}[64], object_count 0..64,
; first_object, oam_used, oam_status, objects_left, object_index.
; Page-aligned oam_buffer[256], exclusive to builder until it publishes ready.
; obj_y is the RAW OAM Y (visible top=Y+1), 0..238; >=239 is hidden.
; Only one 8x8 hardware sprite per object. No clipping, metasprites or DMA here.
; A/X/Y/flags clobbered. Decimal mode clear. Called outside NMI.
; Count >64: hide all entries, status=1 and carry set; no array reads.
build_oam:
    ldx #0
    lda #$ff
hide_oam:
    sta oam_buffer,x
    inx
    inx
    inx
    inx
    bne hide_oam
    lda #0
    sta oam_used
    sta oam_status
    lda object_count
    cmp #65
    bcs oam_invalid
    lda object_count
    beq oam_empty
    sta objects_left
    lda first_object
    cmp object_count
    bcc oam_start
    lda #0
    sta first_object
oam_start:
    sta object_index
oam_next:
    ldx object_index
    lda obj_active,x
    beq oam_skip
    lda obj_y,x
    cmp #239
    bcs oam_skip
    lda oam_used
    asl
    asl
    tay
    lda obj_y,x
    sta oam_buffer,y
    iny
    lda obj_tile,x
    sta oam_buffer,y
    iny
    lda obj_attr,x
    sta oam_buffer,y
    iny
    lda obj_x,x
    sta oam_buffer,y
    inc oam_used
oam_skip:
    inc object_index
    lda object_index
    cmp object_count
    bcc oam_no_wrap
    lda #0
    sta object_index
oam_no_wrap:
    dec objects_left
    bne oam_next
oam_empty:
    clc
    rts
oam_invalid:
    lda #1
    sta oam_status
    sec
    rts
; Rotate ONLY when the caller chooses. This shares dropout; it does not raise
; the hardware's eight-sprites-per-scanline capacity or preserve sprite-0 use.
advance_priority:
    lda object_count
    beq priority_reset
    cmp #65
    bcs priority_reset
    inc first_object
    lda first_object
    cmp object_count
    bcc priority_done
priority_reset:
    lda #0
    sta first_object
priority_done:
    rts

build_oam hides every entry by setting Y=$FF, then packs eligible objects in circular order from first_object. It leaves the input arrays unchanged. An out-of-range starting index normalises to zero for a non-empty list. Empty lists hide everything.

Result Meaning
Carry clear, status 0 Valid count; oam_used is the number packed
Carry set, status 1 Count exceeded 64; all slots hidden and no object arrays read

Only hidden Y values are defined; the other bytes of those entries retain their previous contents. DMA remains the caller’s job. Main must finish building before publishing the page, and must wait for the consuming NMI to acknowledge it before rewriting it.

Rotation is a policy, not extra capacity

Call advance_priority when you want a different object to lead the next list. It wraps at the count, resets empty/invalid counts to zero, and changes A/flags. The complete caller rotates once per accepted display update.

This spreads disappearance over several objects instead of always losing the same ones. It also causes flicker and changes overlap order. It moves the identity in slot zero, so it cannot be dropped unchanged into a sprite-zero split system. A more selective version could reserve player/HUD slots and rotate only less important effects.

A large character made from four entries still spends four entries on every scanline touched by all four. Metasprite grouping, 8×16 sprites, arbitrary clipping and scanline-aware admission need further implementations; they are not supplied by this single-tile builder.

Run it with scrolling

The complete nametable-scrolling cartridge includes this component. Build there with make. Main prepares a finished page and matching camera state; NMI sets $2003=0, transfers page $0200 through $4014, applies scroll, then acknowledges. The caller owns NMI and OAM exclusively.

The execution checks cover counts 0, 1, 8, 9, 12, 63, 64, 65 and 255, circular starts, invalid starting indices, inactive/hidden objects, input preservation and hidden-slot contents. A missing attribute write must fail the same output check. The running scene is captured separately; neither result certifies PAL or a whole-game frame budget.

The PPU’s scanline selection and OAM format are described by Ricoh’s RP2C02 development specification and the NESdev sprite-evaluation reference.

Explore the cross-platform sprite family for software drawing, hardware limits, restoration and overload policies.