Return to Blog

Building a command line tool to modify The Legend of Zelda's color palette

By John Lekberg on September 05, 2020.


In this week's post, you will learn how to build a command line tool that modifies The Legend of Zelda's color palette.

Script source code

zelda-color

#!/usr/bin/env python3

from functools import partial
from itertools import permutations
import math
import pathlib
import re


def MAIN():
    import argparse

    metavar_palette = "C1,C2,C3"
    metavar_ROM = "NES"
    parser = argparse.ArgumentParser(
        description="""
        Modify the 4 color palettes in The Legend of Zelda
        (NES). Supply colors as hex triplets (e.g. 34FD8C).
        """
    )
    parser.add_argument(
        "--grey",
        type=palette_arg,
        metavar=metavar_palette,
        help="3 colors for grey palette",
    )
    parser.add_argument(
        "--red",
        type=palette_arg,
        metavar=metavar_palette,
        help="3 colors for red palette",
    )
    parser.add_argument(
        "--green",
        type=palette_arg,
        metavar=metavar_palette,
        help="3 colors for green palette",
    )
    parser.add_argument(
        "--brown",
        type=palette_arg,
        metavar=metavar_palette,
        help="3 colors for brown palette",
    )
    parser.add_argument(
        "--input",
        required=True,
        type=pathlib.Path,
        metavar=metavar_ROM,
        help="Read Zelda ROM from this file",
    )
    parser.add_argument(
        "--output",
        required=True,
        type=pathlib.Path,
        metavar=metavar_ROM,
        help="Output modified ROM to this file",
    )
    parser.add_argument(
        "--minimize-error",
        action="store_true",
        help="""
        Permute the palette colors to minimize the error
        with the original palette
        """,
    )
    args = parser.parse_args()

    rom = memoryview(bytearray(args.input.read_bytes()))

    for p in ["grey", "red", "green", "brown"]:
        user_palette = getattr(args, p)
        if user_palette is not None:
            if args.minimize_error:
                p_orig = [
                    to_col[b] for _, b in palette_records[p]
                ]
                user_palette = palette_min_error(
                    p_orig=p_orig, p_new=user_palette
                )
            for (addr, _), col in zip(
                palette_records[p], user_palette
            ):
                rom[addr] = to_byte[closest_col(col)]

    args.output.write_bytes(rom)


def palette_arg(x):
    """Parse a comand line argument of 3 hex colors into a
    list.

    x -- str. E.g. "84740C,022FC4,23D403".
    """
    a, b, c = map(css_to_col, x.split(","))
    return [a, b, c]


# col_records - Zelda Color Records
#
# Each tuple is a relationship between
#
# - A color in Zelda (represented as a byte), and
# - A 24-bit RGB color.
#
# Each tuple has 4 bytes (z, r, g, b), where:
#
# - `z` is the Zelda color-byte.
# - `r`, `g`, and `b` are the red-, green-, and
#   blue-components of the RGB color.
#
# This data was generated by hand. I modified the palettes
# with each possible byte and recorded the observed color.
#
col_records = [
(0x00,0x68,0x68,0x68),(0x01,0x00,0x33,0x88),(0x02,0x20,0x1F,0xA6),
(0x03,0x41,0x00,0xA3),(0x04,0x5F,0x00,0x7F),(0x05,0x70,0x00,0x46),
(0x06,0x6E,0x13,0x00),(0x07,0x5A,0x28,0x00),(0x08,0x3A,0x3C,0x00),
(0x09,0x19,0x4D,0x00),(0x0A,0x00,0x56,0x00),(0x0B,0x00,0x53,0x14),
(0x0C,0x00,0x46,0x51),(0x0D,0x00,0x00,0x00),(0x0E,0x00,0x00,0x00),
(0x0F,0x00,0x00,0x00),(0x10,0xAC,0xAC,0xAC),(0x11,0x21,0x62,0xD8),
(0x12,0x48,0x46,0xFF),(0x13,0x76,0x30,0xFE),(0x14,0xA0,0x25,0xCB),
(0x15,0xB6,0x28,0x7C),(0x16,0xB4,0x39,0x2A),(0x17,0x99,0x52,0x00),
(0x18,0x6D,0x6F,0x00),(0x19,0x3F,0x87,0x00),(0x1A,0x1A,0x93,0x00),
(0x1B,0x00,0x8F,0x39),(0x1C,0x00,0x7D,0x8D),(0x1D,0x00,0x00,0x00),
(0x1E,0x00,0x00,0x00),(0x1F,0x00,0x00,0x00),(0x20,0xFF,0xFF,0xFF),
(0x21,0x66,0xAF,0xFF),(0x22,0x92,0x90,0xFF),(0x23,0xC5,0x77,0xFF),
(0x24,0xF2,0x6C,0xFF),(0x25,0xFF,0x70,0xCB),(0x26,0xFF,0x82,0x72),
(0x27,0xE9,0x9E,0x2C),(0x28,0xBB,0xBD,0x00),(0x29,0x88,0xD7,0x00),
(0x2A,0x5F,0xE3,0x38),(0x2B,0x4A,0xDF,0x83),(0x2C,0x4D,0xCC,0xDD),
(0x2D,0x53,0x53,0x53),(0x2E,0x00,0x00,0x00),(0x2F,0x00,0x00,0x00),
(0x30,0xFF,0xFF,0xFF),(0x31,0xBF,0xDE,0xFF),(0x32,0xD2,0xD1,0xFF),
(0x33,0xE7,0xC7,0xFF),(0x34,0xFA,0xC1,0xFF),(0x35,0xFF,0xC3,0xE9),
(0x36,0xFF,0xCB,0xC4),(0x37,0xF7,0xD7,0xA4),(0x38,0xE3,0xE4,0x94),
(0x39,0xCE,0xEF,0x96),(0x3A,0xBC,0xF4,0xAA),(0x3B,0xB2,0xF3,0xCB),
(0x3C,0xB4,0xEA,0xF2),(0x3D,0xB7,0xB7,0xB7),(0x3E,0x00,0x00,0x00),
(0x3F,0x00,0x00,0x00),(0x40,0x68,0x68,0x68),(0x41,0x00,0x33,0x88),
(0x42,0x20,0x1F,0xA6),(0x43,0x41,0x00,0xA3),(0x44,0x5F,0x00,0x7F),
(0x45,0x70,0x00,0x46),(0x46,0x6E,0x13,0x00),(0x47,0x5A,0x28,0x00),
(0x48,0x3A,0x3C,0x00),(0x49,0x19,0x4D,0x00),(0x4A,0x00,0x56,0x00),
(0x4B,0x00,0x53,0x14),(0x4C,0x00,0x46,0x51),(0x4D,0x00,0x00,0x00),
(0x4E,0x00,0x00,0x00),(0x4F,0x00,0x00,0x00),(0x50,0xAC,0xAC,0xAC),
(0x51,0x21,0x62,0xD8),(0x52,0x48,0x46,0xFF),(0x53,0x76,0x30,0xFE),
(0x54,0xA0,0x25,0xCB),(0x55,0xB6,0x28,0x7C),(0x56,0xB4,0x39,0x2A),
(0x57,0x99,0x52,0x00),(0x58,0x6D,0x6F,0x00),(0x59,0x3F,0x87,0x00),
(0x5A,0x1A,0x93,0x00),(0x5B,0x00,0x8F,0x39),(0x5C,0x00,0x7D,0x8D),
(0x5D,0x00,0x00,0x00),(0x5E,0x00,0x00,0x00),(0x5F,0x00,0x00,0x00),
(0x60,0xFF,0xFF,0xFF),(0x61,0x66,0xAF,0xFF),(0x62,0x92,0x90,0xFF),
(0x63,0xC5,0x77,0xFF),(0x64,0xF2,0x6C,0xFF),(0x65,0xFF,0x70,0xCB),
(0x66,0xFF,0x82,0x72),(0x67,0xE9,0x9E,0x2C),(0x68,0xBB,0xBD,0x00),
(0x69,0x88,0xD7,0x00),(0x6A,0x5F,0xE3,0x38),(0x6B,0x4A,0xDF,0x83),
(0x6C,0x4D,0xCC,0xDD),(0x6D,0x53,0x53,0x53),(0x6E,0x00,0x00,0x00),
(0x6F,0x00,0x00,0x00),(0x70,0xFF,0xFF,0xFF),(0x71,0xBF,0xDE,0xFF),
(0x72,0xD2,0xD1,0xFF),(0x73,0xE7,0xC7,0xFF),(0x74,0xFA,0xC1,0xFF),
(0x75,0xFF,0xC3,0xE9),(0x76,0xFF,0xCB,0xC4),(0x77,0xF7,0xD7,0xA4),
(0x78,0xE3,0xE4,0x94),(0x79,0xCE,0xEF,0x96),(0x7A,0xBC,0xF4,0xAA),
(0x7B,0xB2,0xF3,0xCB),(0x7C,0xB4,0xEA,0xF2),(0x7D,0xB7,0xB7,0xB7),
(0x7E,0x00,0x00,0x00),(0x7F,0x00,0x00,0x00),(0x80,0x68,0x68,0x68),
(0x81,0x00,0x33,0x88),(0x82,0x20,0x1F,0xA6),(0x83,0x41,0x00,0xA3),
(0x84,0x5F,0x00,0x7F),(0x85,0x70,0x00,0x46),(0x86,0x6E,0x13,0x00),
(0x87,0x5A,0x28,0x00),(0x88,0x3A,0x3C,0x00),(0x89,0x19,0x4D,0x00),
(0x8A,0x00,0x56,0x00),(0x8B,0x00,0x53,0x14),(0x8C,0x00,0x46,0x51),
(0x8D,0x00,0x00,0x00),(0x8E,0x00,0x00,0x00),(0x8F,0x00,0x00,0x00),
(0x90,0xAC,0xAC,0xAC),(0x91,0x21,0x62,0xD8),(0x92,0x48,0x46,0xFF),
(0x93,0x76,0x30,0xFE),(0x94,0xA0,0x25,0xCB),(0x95,0xB6,0x28,0x7C),
(0x96,0xB4,0x39,0x2A),(0x97,0x99,0x52,0x00),(0x98,0x6D,0x6F,0x00),
(0x99,0x3F,0x87,0x00),(0x9A,0x1A,0x93,0x00),(0x9B,0x00,0x8F,0x39),
(0x9C,0x00,0x7D,0x8D),(0x9D,0x00,0x00,0x00),(0x9E,0x00,0x00,0x00),
(0x9F,0x00,0x00,0x00),(0xA0,0xFF,0xFF,0xFF),(0xA1,0x66,0xAF,0xFF),
(0xA2,0x92,0x90,0xFF),(0xA3,0xC5,0x77,0xFF),(0xA4,0xF2,0x6C,0xFF),
(0xA5,0xFF,0x70,0xCB),(0xA6,0xFF,0x82,0x72),(0xA7,0xE9,0x9E,0x2C),
(0xA8,0xBB,0xBD,0x00),(0xA9,0x88,0xD7,0x00),(0xAA,0x5F,0xE3,0x38),
(0xAB,0x4A,0xDF,0x83),(0xAC,0x4D,0xCC,0xDD),(0xAD,0x53,0x53,0x53),
(0xAE,0x00,0x00,0x00),(0xAF,0x00,0x00,0x00),(0xB0,0xFF,0xFF,0xFF),
(0xB1,0xBF,0xDE,0xFF),(0xB2,0xD2,0xD1,0xFF),(0xB3,0xE7,0xC7,0xFF),
(0xB4,0xFA,0xC1,0xFF),(0xB5,0xFF,0xC3,0xE9),(0xB6,0xFF,0xCB,0xC4),
(0xB7,0xF7,0xD7,0xA4),(0xB8,0xE3,0xE4,0x94),(0xB9,0xCE,0xEF,0x96),
(0xBA,0xBC,0xF4,0xAA),(0xBB,0xB2,0xF3,0xCB),(0xBC,0xB4,0xEA,0xF2),
(0xBD,0xB7,0xB7,0xB7),(0xBE,0x00,0x00,0x00),(0xBF,0x00,0x00,0x00),
(0xC0,0x68,0x68,0x68),(0xC1,0x00,0x33,0x88),(0xC2,0x20,0x1F,0xA6),
(0xC3,0x41,0x00,0xA3),(0xC4,0x5F,0x00,0x7F),(0xC5,0x70,0x00,0x46),
(0xC6,0x6E,0x13,0x00),(0xC7,0x5A,0x28,0x00),(0xC8,0x3A,0x3C,0x00),
(0xC9,0x19,0x4D,0x00),(0xCA,0x00,0x56,0x00),(0xCB,0x00,0x53,0x14),
(0xCC,0x00,0x46,0x51),(0xCD,0x00,0x00,0x00),(0xCE,0x00,0x00,0x00),
(0xCF,0x00,0x00,0x00),(0xD0,0xAC,0xAC,0xAC),(0xD1,0x21,0x62,0xD8),
(0xD2,0x48,0x46,0xFF),(0xD3,0x76,0x30,0xFE),(0xD4,0xA0,0x25,0xCB),
(0xD5,0xB6,0x28,0x7C),(0xD6,0xB4,0x39,0x2A),(0xD7,0x99,0x52,0x00),
(0xD8,0x6D,0x6F,0x00),(0xD9,0x3F,0x87,0x00),(0xDA,0x1A,0x93,0x00),
(0xDB,0x00,0x8F,0x39),(0xDC,0x00,0x7D,0x8D),(0xDD,0x00,0x00,0x00),
(0xDE,0x00,0x00,0x00),(0xDF,0x00,0x00,0x00),(0xE0,0xFF,0xFF,0xFF),
(0xE1,0x66,0xAF,0xFF),(0xE2,0x92,0x90,0xFF),(0xE3,0xC5,0x77,0xFF),
(0xE4,0xF2,0x6C,0xFF),(0xE5,0xFF,0x70,0xCB),(0xE6,0xFF,0x82,0x72),
(0xE7,0xE9,0x9E,0x2C),(0xE8,0xBB,0xBD,0x00),(0xE9,0x88,0xD7,0x00),
(0xEA,0x5F,0xE3,0x38),(0xEB,0x4A,0xDF,0x83),(0xEC,0x4D,0xCC,0xDD),
(0xED,0x53,0x53,0x53),(0xEE,0x00,0x00,0x00),(0xEF,0x00,0x00,0x00),
(0xF0,0xFF,0xFF,0xFF),(0xF1,0xBF,0xDE,0xFF),(0xF2,0xD2,0xD1,0xFF),
(0xF3,0xE7,0xC7,0xFF),(0xF4,0xFA,0xC1,0xFF),(0xF5,0xFF,0xC3,0xE9),
(0xF6,0xFF,0xCB,0xC4),(0xF7,0xF7,0xD7,0xA4),(0xF8,0xE3,0xE4,0x94),
(0xF9,0xCE,0xEF,0x96),(0xFA,0xBC,0xF4,0xAA),(0xFB,0xB2,0xF3,0xCB),
(0xFC,0xB4,0xEA,0xF2),(0xFD,0xB7,0xB7,0xB7),(0xFE,0x00,0x00,0x00),
(0xFF,0x00,0x00,0x00),
]

# palette_records - Zelda Palette Address Records
#
# Zelda (NES) has 4 color palettes, nicknamed "grey", "red",
# "green", and "brown". Each palette has 3 main colors:
#
# 1. Mountains, trees, rocks.
# 2. Pathway.
# 3. Water.
#
# For more information see "Zelda Overworld Color Hacking"
# (Dr. Floppy, 2006).
#
# In this data, the entries are structured like
#
#   palette: [(a1,z1),(a2,z2),(a3,z3)]
#
# where:
#
# - `palette` is "grey", "red", "green", "brown".
# - `a1` is the memory address of the palette entry for
#   color 1 (mountains, trees, rocks).
# - `z1` is the default color-byte (q.v. `z` in
#   `col_records`) for palette color 1.
# - Similarly for `a2`-`z2` (pathway) and `a3`-`z3` (water).
#
# REFERENCES
#
# Dr. Floppy. "Zelda Overworld Color Hacking." 2006.
# http://www.romhacking.net/documents/385/
#
palette_records = {
  "grey":  [(0x19314,0x30),(0x19315,0x00),(0x19316,0x12)],
  "red":   [(0x19318,0x16),(0x19319,0x27),(0x1931A,0x36)],
  "green": [(0x1931C,0x1A),(0x1931D,0x37),(0x1931E,0x12)],
  "brown": [(0x19320,0x17),(0x19321,0x37),(0x19322,0x12)],
}

# all_col  -- set[tuple]. Set of all 24-bit RGB colors
#             present in `col_records`.
# to_byte  -- dict[tuple, int]. Map 24-bit RGB color to a
#             Zelda color-byte.
# to_col   -- dict[int, tuple]. Map a Zelda color-byte to
#             its 24-bit RGB color.
all_col = {(r, g, b) for _, r, g, b in col_records}
to_byte = {(r, g, b): zb for zb, r, g, b in col_records}
to_col = {zb: (r, g, b) for zb, r, g, b in col_records}


def col_dist(c1, c2):
    """Return the distance between two 24-bit RGB colors.

    c1, c2 -- tuple[int, int, int]. 24-bit RGB color.
    
    Treat `c1` and `c2` as tuples in ℝ³ and return the
    Euclidean distance.
    """
    return math.sqrt(
        sum((x1 - x2) ** 2 for x1, x2 in zip(c1, c2))
    )


def closest_col(c):
    """Given a 24-bit RGB color, find the closest available
    color supported by Zelda (NES).

    c -- tuple[int, int, int]. 24-bit RGB color.

    Find the closest color by minimizing `col_dist`.
    """
    return min(all_col, key=partial(col_dist, c))


def css_to_col(css):
    """Turn a hex triplet representing a 24-bit RGB color
    into a tuple of components.

    css -- str. Hex triplet. E.g. "FF4CD1".
    """
    assert re.fullmatch("(?i)[0-9a-z]{6}", css)
    parse = partial(int, base=16)
    r, g, b = css[0:2], css[2:4], css[4:6]
    return parse(r), parse(g), parse(b)


def palette_min_error(*, p_orig, p_new):
    """Find the permutation of `p_new` that minimizes the
    error with `p_orig`.

    p_new, p_orig -- list[tuple]. List of 24-bit RGB colors.
    """
    assert len(p_orig) == len(p_new)
    error = lambda p_perm: sum(
        col_dist(x, y)
        for x, y in zip(original_palette, perm)
    )
    return min(permutations(new_palette), key=error)


if __name__ == "__main__":
    MAIN()
$ zelda-color --help
usage: zelda-color [-h] [--grey C1,C2,C3] [--red C1,C2,C3]
                   [--green C1,C2,C3] [--brown C1,C2,C3]
                   --input NES --output NES [--minimize-error]

Modify the 4 color palettes in The Legend of Zelda (NES).
Supply colors as hex triplets (e.g. 34FD8C).

optional arguments:
  -h, --help        show this help message and exit
  --grey C1,C2,C3   3 colors for grey palette
  --red C1,C2,C3    3 colors for red palette
  --green C1,C2,C3  3 colors for green palette
  --brown C1,C2,C3  3 colors for brown palette
  --input NES       Read Zelda ROM from this file
  --output NES      Output modified ROM to this file
  --minimize-error  Permute the palette colors to minimize
                    the error with the original palette

Using the script to modify Zelda's palette

Here are some color palettes that I like:


$ zelda-color --green FF71CE,4B0082,06D6A0 \
              --brown 5F7AFF,4B0082,06D6A0 \
              --input zelda.nes --output test.nes
AESTHETIC

$ zelda-color --green FF0000,000000,FF0000 \
              --brown FF0000,000000,FF0000 \
              --input zelda.nes --output test.nes

$ zelda-color --green D66846,7EAA62,EB0BB4 \
              --brown D66846,7EAA62,EB0BB4 \
              --input zelda.nes --output test.nes

How the script works

I represent the ROM in memory as a memoryview object.

For the details of how The Legend of Zelda stores the color palette information (e.g. the "green" palette, the "red" palette), read this document:

Dr. Floppy. "Zelda Overworld Color Hacking." 2006. http://www.romhacking.net/documents/385/

I couldn't find documentation on the relationship between the color-bytes (e.g. 5A16) and the corresponding 24-bit RGB colors (e.g. 1A930016). So I manually collected the data by:

Because the available colors are a subset of all 24-bit RGB colors, I need to find the "closest available color" to a given color. I do this by representing 24-bit RGB colors as tuples in 3 and finding the available color that minimizes the Euclidean distance. Because there are only 256 available colors, I can use a brute force search to find the closest available color.

In conclusion...

In this week's post you learned how to build a command line tool that modifies The Legend of Zelda's color palette.

My challenge to you:

To find available colors, I used Euclidean distance in the RGB color space.

Try to rewrite the code to use the International Commission on Illumination's ΔE*ab color difference metric. For more information, read these documents:

If you enjoyed this week's post, share it with your friends and stay tuned for next week's post. See you then!


(If you spot any errors or typos on this post, contact me via my contact page.)