279. Nametable with attributes

Render nametable background using attribute-selected palettes.

Lesson 279 of 356 · tests/chapter_05_rendering_pipeline/test_279_nametable_with_attributes.py

Reference

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

File to update

emulator/rendering/nametable_renderer.py

Why this step exists

The first nametable renderer used one shared 4-color palette for the entire background. That was a useful starting point, but real NES backgrounds use the attribute table to choose between four background palettes in different regions.

This step adds a new renderer instead of changing the old one

old/simple:
    nametable_to_framebuffer(nametable, pattern_table, palette)

new/attribute-aware:
    nametable_with_attributes_to_framebuffer(
        nametable,
        attribute_table,
        pattern_table,
        background_palettes,
    )

What are background_palettes? background_palettes is a list of four 4-color RGB palettes:

background_palettes[0] -> palette ID 0
background_palettes[1] -> palette ID 1
background_palettes[2] -> palette ID 2
background_palettes[3] -> palette ID 3

Each tile pixel still produces only a color index 0-3. The attribute table selects which palette ID to use for the tile region:

palette_id = get_attribute_palette_id(attribute_table, tile_x, tile_y)
rgb = background_palettes[palette_id][color_index]

Important hardware model

The attribute table does not store RGB colors. It selects a background palette ID. For this step, we pass already-resolved RGB background palettes manually. Later, another step can build those palettes from PPU palette RAM and NES_PALETTE_RGB.

Suggested implementation example

from emulator.rendering.attribute_table import get_attribute_palette_id

BackgroundPalettes = list[list[RGBColor]]


def nametable_with_attributes_to_framebuffer(
    nametable_bytes: bytes,
    attribute_table: bytes,
    pattern_table_bytes: bytes,
    background_palettes: BackgroundPalettes,
) -> Framebuffer:
    if len(nametable_bytes) != NAMETABLE_SIZE:
        raise ValueError("Nametable visible tile area must be 960 bytes")

    decoded_tiles = decode_pattern_table(pattern_table_bytes)
    framebuffer = Framebuffer(width=BACKGROUND_WIDTH, height=BACKGROUND_HEIGHT)

    for tile_y in range(NAMETABLE_ROWS):
        for tile_x in range(NAMETABLE_TILES_PER_ROW):
            nametable_index = tile_y * NAMETABLE_TILES_PER_ROW + tile_x
            tile_id = nametable_bytes[nametable_index]
            tile = decoded_tiles[tile_id]

            palette_id = get_attribute_palette_id(attribute_table, tile_x, tile_y)
            palette = background_palettes[palette_id]

            for row in range(CHR_TILE_HEIGHT):
                for col in range(CHR_TILE_WIDTH):
                    color_index = tile[row][col]
                    rgb = palette[color_index]

                    pixel_x = tile_x * CHR_TILE_WIDTH + col
                    pixel_y = tile_y * CHR_TILE_HEIGHT + row
                    framebuffer.set_pixel(pixel_x, pixel_y, rgb)

    return framebuffer

Compatibility rule

Keep nametable_to_framebuffer(..., palette) unchanged. It remains the simple one-palette renderer used by earlier tests.

Out of scope

  • PPU palette RAM lookup
  • universal background color behavior
  • scrolling
  • sprites
  • OAMDMA
  • pygame display

Run this lesson

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