274. Nes palette rgb

Define a minimal NES RGB palette approximation.

Lesson 274 of 356 · tests/chapter_05_rendering_pipeline/test_274_nes_palette_rgb.py

Reference palette file

https://www.nesdev.org/wiki/File:2C02G_U_wiki.pal

File to create

emulator/rendering/nes_palette.py

Why this step exists

The rendering pipeline can now convert color-index grids into Framebuffer data, but callers still need to provide an RGB palette manually.

This step defines a practical 64-color NES RGB palette approximation

NES color index $00-$3F -> RGB tuple

Important hardware model

The NES does not store RGB colors directly. The PPU has palette RAM containing NES color indexes, and the PPU's analog video circuitry turns those indexes into a video signal. In an emulator, we approximate that output with RGB tuples.

Real-ish flow

pixel palette entry
    -> PPU palette RAM value $00-$3F
    -> PPU hardware color generator / analog video signal
    -> TV color

Emulator flow

pixel palette entry
    -> PPU palette RAM value $00-$3F
    -> NES_PALETTE_RGB[index]
    -> Framebuffer RGB pixel

What is 2C02G_U_wiki.pal? 2C02G is an NTSC NES PPU revision. The referenced .pal file is an RGB palette approximation for that PPU/output behavior. It is not a literal RGB table stored inside NES hardware.

Why does the source file have more than 64 colors? Some .pal files include PPUMASK emphasis variants. The base NES color index range is 64 colors, but emphasis can create 8 display variants:

64 base colors * 8 emphasis states = 512 RGB entries

For this tutorial step, use only the first 64 RGB entries: the normal no-emphasis palette. Emphasis support can be added later when rendering uses PPUMASK bits 5-7.

How to reproduce this table from the downloaded .pal file:

file = "2C02G_U_wiki.pal"
data = open(file, "rb").read()

for i in range(0, 64 * 3, 3):
    rgb = data[i:i + 3]
    if len(rgb) == 3:
        r, g, b = rgb[0], rgb[1], rgb[2]
        print(f"({r},{g},{b}),")

Suggested implementation example

from emulator.rendering.framebuffer import RGBColor

NES_PALETTE_SIZE = 64

NES_PALETTE_RGB: list[RGBColor] = [
    (87,87,87),     (0,12,142),     (8,0,166),      (52,0,150),      (85,0,97),      (99,0,21),      (90,0,0),       (60,14,0),
    (17,40,0),      (0,59,0),       (0,66,0),       (0,58,5),        (0,38,82),      (0,0,0),        (0,0,0),        (0,0,0),

    (165,165,165),  (0,65,217),     (47,30,255),    (103,4,242),     (148,0,180),    (170,0,87),     (163,24,0),     (128,57,0),
    (75,91,0),      (19,118,0),     (0,129,0),      (0,121,35),      (0,98,136),     (0,0,0),        (0,0,0),        (0,0,0),

    (255,255,255),  (74,159,255),   (121,126,255),  (175,99,255),    (221,85,255),   (247,87,194),   (247,106,99),   (220,136,16),
    (174,169,0),    (120,196,0),    (74,210,17),    (47,207,100),    (47,189,196),   (65,65,65),     (0,0,0),        (0,0,0),

    (255,255,255),  (185,221,255),  (202,209,255),  (222,198,255),   (240,192,255),  (252,192,238),  (253,198,202),  (245,208,170),
    (228,221,149),  (208,232,146),  (189,238,162),  (178,238,192),   (176,232,227),  (179,179,179),  (0,0,0),        (0,0,0),
]

def get_nes_rgb_color(index: int) -> RGBColor:
    return NES_PALETTE_RGB[index & 0x3F]

Why mask with $3F?

NES color indexes are 6-bit values

$00-$3F

Masking keeps lookup in range

index & 0x3F

Examples

get_nes_rgb_color(0x40) == get_nes_rgb_color(0x00)
get_nes_rgb_color(0x41) == get_nes_rgb_color(0x01)

Out of scope

  • PPUMASK emphasis colors
  • PAL/Dendy palettes
  • CRT/NTSC signal simulation
  • PPU palette RAM lookup at $3F00-$3F1F
  • pygame display

Run this lesson

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