OAM stores sprite attributes for the PPU. Understanding how OAM works is critical for sprite programming.
OAM is 544 bytes total:
| Section | Bytes | Content |
|---|---|---|
| Main Table | 0-511 | 128 sprites × 4 bytes each |
| Extended Table | 512-543 | 128 sprites × 2 bits each |
| Byte | Content |
|---|---|
| 0 | X position (lower 8 bits) |
| 1 | Y position |
| 2 | Tile number (lower 8 bits) |
| 3 | Attributes (see below) |
This is the most important thing to understand about OAM writes.
The SNES uses a 16-bit internal write buffer for OAM:
$2104 → goes to internal low byte buffer only$2104 → goes to high byte buffer AND commits both bytes to OAMIf you only write one byte (e.g., just the X position), nothing happens. The byte sits in the internal buffer but is never committed to actual OAM.
Always write bytes in pairs:
PVSnesLib uses a shadow buffer approach:
oamMemory)For direct OAM writes without a shadow buffer, you must respect the pair-write requirement.
| Register | Address | Description |
|---|---|---|
| OAMADDL | $2102 | OAM address (low byte) |
| OAMADDH | $2103 | OAM address (high byte) + priority rotation |
| OAMDATA | $2104 | OAM data write |
For the extended table:
$2101 (OBJSEL) configures sprite sizes:
| Value | Small | Large |
|---|---|---|
| 0x00 | 8x8 | 16x16 |
| 0x20 | 8x8 | 32x32 |
| 0x40 | 8x8 | 64x64 |
| 0x60 | 16x16 | 32x32 |
| 0x80 | 16x16 | 64x64 |
| 0xA0 | 32x32 | 64x64 |
To hide a sprite, set its Y position to 240 (off-screen):
The SNES PPU renders sprites with a 1-scanline vertical delay: a sprite whose OAM_Y = N is drawn on scanlines N+1 through N+8, not N through N+7. The OAM is scanned and tile-fetched on the previous scanline, so by the time the sprite is composited it has already advanced by one.
This is documented hardware behavior, confirmed in two places on the SNESdev community wiki:
The X axis has no equivalent quirk.
The Y semantics depend on which API you use. Two camps:
Camp 1 — Y is the rendered top scanline (visual_top)**
The API auto-subtracts 1 before writing OAM, so the value you pass is what you see on screen.
| API | Notes |
|---|---|
oamSet(id, x, y, ...) | auto Y-1 in sprite_oamset.asm |
oamSetY(id, y) / oamSetXY(...) | auto Y-1 in sprite.c |
oamDrawMeta / oamDrawMetaFlip / oamDrawMetasprite | call oamSet |
Camp 2 — Y is the legacy PVSnesLib y_logical (= visual_top - 1)
The dynamic sprite engine inherits PVSnesLib's collision-and-render contract: collision math sets oambuffer[id].oamy = visual_top - 1, the engine writes that raw to OAM, and the PPU's +1 quirk lifts it back to visual_top on screen. **Do not pre-subtract when feeding these APIs.
| API | Notes |
|---|---|
oamDynamicDraw(id) | use oambuffer[id].oamy raw |
oamMetaDrawDyn{8,16,32} | dynamic engine path, same convention |
Camp 3 — direct OAM writes
When bypassing both APIs and writing oamMemory[id*4 + 1] yourself, no one compensates for you. Subtract 1 manually so the rendered top matches the caller's intent.
Sentinel values used to hide a sprite (OBJ_HIDE_Y = 240, OAM_Y_OFFSCREEN = 224) are not compensated — they are not logical positions, just markers that push the sprite off the visible area.
Most of OpenSNES treats Y as visual_top, which is what programmers expect. The dynamic engine is the exception because its collision-driven oamy field is set by ported PVSnesLib code that already accounts for the quirk. Adding our own compensation on top would double the offset and lift sprites 2 px off the ground — easily visible on slopemario / likemario.
OAM writes should be done during VBlank or forced blank (screen off). Writing during active display can cause visual glitches.