Skip to content

Fix colors of Emerald static sprites - #274

Open
Delgan wants to merge 2 commits into
PokeAPI:masterfrom
Delgan:fix-emerald-colors
Open

Fix colors of Emerald static sprites#274
Delgan wants to merge 2 commits into
PokeAPI:masterfrom
Delgan:fix-emerald-colors

Conversation

@Delgan

@Delgan Delgan commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Final cleanup pass for the Gen 3 sprites.

Again, I re-generated the sprites from pokeemerald using gbagfx. This time, however, there is no equivalent on Bulbapedia (the sprites there are animated), thus I could not use them as reference.

The tricky part with Pokémon Emerald is that the front sprites consist of two frames (for the animation) in one image. So, we have to crop it correctly first before calling gbagfx to generate the .png. Note that front.png in the pokeemerald sources must be ignored, it's not the in-game sprite. Also, we cannot simply extract the frame from the animated APNG because dimensions are not always 64x64.

I compared the output to the static frames of ruby-sapphire and found out that most of them were byte-wise equal. Besides, I also extracted last frame of a few animated sprites and verified that it was pixel-wise equal to the generated static sprite. Therefore, I'm pretty confident the process is correct.

For the back sprites, the conversion was straightforward, and most of the sprites in the repository were already correct.


Script used as reference, in case I need it again in the future.

Source code
from pathlib import Path
import argparse
import subprocess
import tqdm
import re
import tempfile
from enum import Enum, auto
from dataclasses import dataclass

class SpriteType(Enum):
    FRONT = auto()
    BACK = auto()

class PaletteType(Enum):
    NORMAL = auto()
    SHINY = auto()

@dataclass
class GbagfxSprite:
    dex: int
    form_id: None | str
    type: SpriteType
    palette: PaletteType
    bpp_path: Path
    pal_path: Path

def _load_pokemon_dex_numbers(repo_root: Path = Path(".")):
    pokedex = repo_root / "include/constants/pokedex.h"
    national_pattern = re.compile(r"NATIONAL_DEX_([A-Z0-9_]+),")
    names = [
        match.group(1).lower()
        for match in national_pattern.finditer(pokedex.read_text())
        if not match.group(1).startswith("OLD_UNOWN")
        and match.group(1) != "NONE"
    ]

    assert len(names) == 386

    return list(enumerate(names, start=1))


def _halve_4bpp_sprite(image: Path, keep_top: bool):
    data = image.read_bytes()
    midpoint = len(data) // 2
    return data[:midpoint] if keep_top else data[midpoint:]


def _gen_gbagfx_sprites():
    bpps = [
        (SpriteType.FRONT, "anim_front.4bpp"),
        (SpriteType.BACK, "back.4bpp"),
    ]
    pals = [
        (PaletteType.NORMAL, "normal.gbapal"),
        (PaletteType.SHINY, "shiny.gbapal"),
    ]

    base_folder = Path("graphics/pokemon")

    def _gen_forms(dex, assets, pal_name, bpp_name):
        if dex == 201:
            yield None, assets / pal_name, assets / "a" / bpp_name
            yield "201-exclamation", assets / pal_name, assets / "exclamation_mark" / bpp_name
            yield "201-question", assets / pal_name, assets / "question_mark" / bpp_name
            for letter in map(chr, range(97, 123)):
                yield f"201-{letter}", assets / pal_name, assets / letter / bpp_name
        elif dex == 351:
            yield None, assets / "normal" / pal_name, assets / "normal" / bpp_name
            yield "10013", assets / "sunny" / pal_name, assets / "sunny" / bpp_name
            yield "10014", assets / "rainy" / pal_name, assets / "rainy" / bpp_name
            yield "10015", assets / "snowy" / pal_name, assets / "snowy" / bpp_name
        elif dex == 386:
            yield None, assets / pal_name, assets / bpp_name
            yield "10003", assets / pal_name, assets / bpp_name
        else:
            yield None, assets / pal_name, assets / bpp_name

    for dex, name in _load_pokemon_dex_numbers():
        assets = base_folder / name
        for type, bpp_name in bpps:
            for palette, pal_name in pals:
                for form_id, pal_path, bpp_path in _gen_forms(dex, assets, pal_name, bpp_name):
                    yield GbagfxSprite(
                        dex=dex,
                        form_id=form_id,
                        type=type,
                        palette=palette,
                        bpp_path=bpp_path,
                        pal_path=pal_path,
                    )

def build_all_with_gbagfx(output: Path):
    folders = {
        (SpriteType.FRONT, PaletteType.NORMAL): "front-normal",
        (SpriteType.BACK, PaletteType.NORMAL): "back-normal",
        (SpriteType.FRONT, PaletteType.SHINY): "front-shiny",
        (SpriteType.BACK, PaletteType.SHINY): "back-shiny",
    }
    
    for dir in folders.values():
        (output / dir).mkdir(exist_ok=True, parents=True)

    with tempfile.TemporaryDirectory() as dir:
        for sprite in tqdm.tqdm(_gen_gbagfx_sprites(), total=418 * 4):
            folder = output / folders[(sprite.type, sprite.palette)]
            id = sprite.form_id or str(sprite.dex)
            dst = folder / f"{id}.png"

            if dst.exists():
                continue

            subprocess.run(["make", str(sprite.bpp_path), str(sprite.pal_path)], check=True, capture_output=True)

            if sprite.dex == 351:
                temp_bpp_path = sprite.bpp_path
            elif sprite.dex == 386:
                crop = _halve_4bpp_sprite(sprite.bpp_path, keep_top=sprite.form_id is None)
                temp_bpp_path = Path(dir) / f"{folder.name}-{id}.4bpp"
                temp_bpp_path.write_bytes(crop)
            elif sprite.type is SpriteType.FRONT:
                crop = _halve_4bpp_sprite(sprite.bpp_path, keep_top=True)
                temp_bpp_path = Path(dir) / f"{folder.name}-{id}.4bpp"
                temp_bpp_path.write_bytes(crop)
            else:
                temp_bpp_path = sprite.bpp_path

            subprocess.run(["tools/gbagfx/gbagfx", temp_bpp_path, str(dst), "-width", "8", "-palette", str(sprite.pal_path), "-object"], check=True, capture_output=True)




def main():
    parser = argparse.ArgumentParser(description="Build and compare FRLG sprites")
    parser.add_argument("--output", required=True, type=str,help="Folder to store the sprites")
    args = parser.parse_args()

    output = Path(args.output)

    build_all_with_gbagfx(output / "gbagfx")

if __name__ == "__main__":
    main()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant