278. Attribute table palette selection

Decode background palette selection from a nametable attribute table.

Lesson 278 of 356 · tests/chapter_05_rendering_pipeline/test_278_attribute_table_palette_selection.py

Reference

https://www.nesdev.org/wiki/PPU_attribute_tables

File to create

emulator/rendering/attribute_table.py

Why this step exists

The current nametable renderer uses one shared 4-color palette for every tile. Real NES backgrounds choose between four background sub-palettes using the nametable's attribute table.

This step does not render with attributes yet. It only answers one small question:

For tile coordinate (tile_x, tile_y), which palette ID does the attribute
table select?

What is an attribute table?

Each nametable has

960 bytes tile IDs
64 bytes attribute table

The attribute table is an 8x8 byte grid. Each attribute byte covers a 4x4 tile area, which is 32x32 pixels.

One attribute byte is split into four 2x2 tile quadrants

+-----------------------+
| top-left | top-right  |
|  2x2     |   2x2      |
+----------+------------+
| bottom-l | bottom-r   |
|  2x2     |   2x2      |
+-----------------------+

Each quadrant stores a 2-bit palette ID

0b00 -> palette 0
0b01 -> palette 1
0b10 -> palette 2
0b11 -> palette 3

Important distinction

The attribute table does not store RGB colors and does not store NES color indexes. It selects which background sub-palette to use. Later, palette RAM and the NES RGB palette will turn that selection into actual colors.

Bit layout inside one attribute byte

bits 0-1 -> top-left quadrant
bits 2-3 -> top-right quadrant
bits 4-5 -> bottom-left quadrant
bits 6-7 -> bottom-right quadrant

This step implements: unpack/read one quadrant from the byte.

Readable implementation example

TABLE_SIZE = 64
BYTES_PER_ROW = 8


def get_attribute_palette_id(
    attribute_table: bytes,
    tile_x: int,
    tile_y: int,
) -> int:
    if len(attribute_table) != TABLE_SIZE:
        raise ValueError("Attribute table must be 64 bytes")

    attribute_x = tile_x // 4
    attribute_y = tile_y // 4
    attribute_index = attribute_y * BYTES_PER_ROW + attribute_x
    attribute_byte = attribute_table[attribute_index]

    quadrant_x = (tile_x % 4) // 2
    quadrant_y = (tile_y % 4) // 2

    is_top_left = quadrant_x == 0 and quadrant_y == 0
    is_top_right = quadrant_x == 1 and quadrant_y == 0
    is_bottom_left = quadrant_x == 0 and quadrant_y == 1

    if is_top_left:
        return attribute_byte & 0b11

    if is_top_right:
        return (attribute_byte >> 2) & 0b11

    if is_bottom_left:
        return (attribute_byte >> 4) & 0b11

    return (attribute_byte >> 6) & 0b11

Why tile_x // 4 and tile_y // 4? Because each attribute byte covers 4x4 tiles.

Why (tile_x % 4) // 2?

Inside that 4x4 area

tile positions 0,1 belong to quadrant 0
tile positions 2,3 belong to quadrant 1

So

0 % 4 // 2 -> 0
1 % 4 // 2 -> 0
2 % 4 // 2 -> 1
3 % 4 // 2 -> 1

Out of scope

  • rendering nametables with attributes
  • PPU palette RAM lookup
  • RGB palette conversion
  • scrolling
  • sprites
  • pygame display

Run this lesson

uv run pytest tests/chapter_05_rendering_pipeline/test_278_attribute_table_palette_selection.py -v