276. Nametable to framebuffer

Render a simplified nametable background into framebuffer data.

Lesson 276 of 356 · tests/chapter_05_rendering_pipeline/test_276_nametable_to_framebuffer.py

Reference

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

File to create

emulator/rendering/nametable_renderer.py

Why this step exists

Pattern tables contain tile graphics, but they do not describe the background layout. Nametables provide that layout.

What is a nametable? A nametable is PPU memory that stores which background tile appears at each tile cell on the screen.

Minimal example

nametable[0] = 5

This means

top-left 8x8 background cell uses pattern table tile #5

Important distinction

pattern table = tile graphics dictionary
nametable     = tile layout/map
palette       = colors used by tile pixels

Simplified rendering model for this step

nametable tile ID
    -> decoded_tiles[tile_id]
    -> tile pixel color index 0-3
    -> same shared 4-color palette[color_index]
    -> framebuffer pixel

Real NES nametable memory

960 bytes tile IDs
64 bytes attribute table

This step intentionally uses only the 960 visible tile bytes and one shared 4-color palette for all tiles. Attribute-table palette selection is a later step.

Important simplification

This first nametable renderer is a basic starting point. Every tile uses the same 4-color palette. Later tests add an attribute-aware renderer where different screen regions can choose different background palettes.

Screen size from nametable geometry

32 tiles across * 8 pixels = 256 pixels
30 tiles down   * 8 pixels = 240 pixels

Suggested implementation example

from emulator.ppu.chr_decoder import (
    CHR_TILE_HEIGHT,
    CHR_TILE_WIDTH,
    decode_pattern_table,
)
from emulator.rendering.framebuffer import Framebuffer, RGBColor

NAMETABLE_ROWS = 30
NAMETABLE_TILES_PER_ROW = 32
NAMETABLE_SIZE = NAMETABLE_TILES_PER_ROW * NAMETABLE_ROWS

BACKGROUND_WIDTH = NAMETABLE_TILES_PER_ROW * CHR_TILE_WIDTH
BACKGROUND_HEIGHT = NAMETABLE_ROWS * CHR_TILE_HEIGHT


def nametable_to_framebuffer(
    nametable_bytes: bytes,
    pattern_table_bytes: bytes,
    palette: list[RGBColor],
) -> 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]

            for row in range(CHR_TILE_HEIGHT):
                for col in range(CHR_TILE_WIDTH):
                    # Basic starting point: every tile uses the same
                    # 4-color palette. A later renderer will use the
                    # attribute table to select different palettes.
                    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

Architecture rule

Do not duplicate CHR decoding logic here. Reuse decode_pattern_table().

Out of scope

  • attribute table decoding
  • per-tile palette selection
  • scrolling
  • nametable mirroring
  • reading directly from PPU bus
  • sprites
  • OAMDMA
  • pygame display

Run this lesson

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