#!/usr/bin/env python3
"""Build game data directly from a canonical Pokemon Red/Blue/Yellow ROM.

It accepts one user-provided, canonical US Gen-1 ROM. Symbol addresses and
assembly-erased names are bundled as non-ROM metadata, so no pret checkout,
RGBDS build, or external .sym file is required for the public ROM path.
"""

from __future__ import annotations

import argparse
import copy
import json
import os
import shutil
import sys
from collections import deque

from PIL import Image

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from extract import util  # noqa: E402
from rom_data import (  # noqa: E402
    CANONICAL_BLUE_SHA1, CANONICAL_RED_SHA1, CANONICAL_YELLOW_SHA1,
    RomImage, SymbolTable, bcd, decode_text, decompress_pic, load_manifest,
    read_string,
)


DATASETS = (
    "constants", "tilesets", "maps", "font", "sprites", "moves", "items",
    "type_chart", "palettes", "icons", "pokemon", "trainers", "encounters",
    "text", "field", "battle_anims",
)

# Sound is decoded by the Lua importer (src/import/RomExtractor.lua), not here,
# so --clean must step around it rather than delete what it cannot rebuild.
UNOWNED_DATA = ("audio.lua",)
UNOWNED_ASSETS = ("audio",)


def clean_generated(out_dir, assets_dir):
    """Empty the generated dirs, keeping artifacts this tool never writes."""
    kept = []
    for path, spared in ((out_dir, UNOWNED_DATA), (assets_dir, UNOWNED_ASSETS)):
        if not os.path.isdir(path):
            continue
        for name in os.listdir(path):
            target = os.path.join(path, name)
            if name in spared:
                kept.append(target)
            elif os.path.isdir(target):
                shutil.rmtree(target)
            else:
                os.remove(target)
    return kept

_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
VERSION_MANIFESTS = {
    "red": os.path.join(_TOOLS_DIR, "rom_manifest.json"),
    "blue": os.path.join(_TOOLS_DIR, "rom_manifest_blue.json"),
    "yellow": os.path.join(_TOOLS_DIR, "rom_manifest_yellow.json"),
}
VERSION_SHA1 = {
    "red": CANONICAL_RED_SHA1,
    "blue": CANONICAL_BLUE_SHA1,
    "yellow": CANONICAL_YELLOW_SHA1,
}
SHA1_TO_VERSION = {sha1: version for version, sha1 in VERSION_SHA1.items()}

GB_SHADES = (
    (255, 255, 255, 255),
    (170, 170, 170, 255),
    (85, 85, 85, 255),
    (0, 0, 0, 255),
)


def _apply_title_obp0(image):
    """Title rOBP0=%11100000 ($E0): OBJ shades 1 and 2 → white, 3 → black.

    Eye OAM is baked into the MEWMON-colored BG PNG; without this remap the
    shade-1 glints become body yellow under the title palette
    (pokeyellow engine/movie/title.asm after PlacePikachu).
    """
    pixels = image.load()
    w, h = image.size
    mid, dark = GB_SHADES[1][0], GB_SHADES[2][0]
    for y in range(h):
        for x in range(w):
            r, g, b, a = pixels[x, y]
            if a == 0:
                continue
            if abs(r - mid) <= 2 or abs(r - dark) <= 2:
                pixels[x, y] = GB_SHADES[0]
    return image


def _symbol(symbols, name):
    try:
        return symbols[name]
    except KeyError as exc:
        raise ValueError(f"required symbol {name!r} is missing") from exc


def _has_symbol(symbols, name):
    return name in symbols.by_name


def resolve_manifest_path(version, manifest_arg):
    """Pick the shipped manifest for --version, or an explicit --manifest path."""
    if manifest_arg is not None:
        return manifest_arg
    path = VERSION_MANIFESTS[version]
    if not os.path.isfile(path):
        raise ValueError(
            f"manifest for version {version!r} is missing: {path} "
            f"(generate it before extracting)")
    return path


def version_for_manifest(manifest, requested_version=None, manifest_explicit=False):
    """Resolve game version from an explicit flag or the manifest romSha1."""
    rom_sha1 = manifest.get("romSha1")
    detected = SHA1_TO_VERSION.get(rom_sha1)
    if manifest_explicit:
        # Explicit --manifest wins: hash/version come from the file itself.
        return detected or requested_version or "red"
    if requested_version and detected and requested_version != detected:
        raise ValueError(
            f"--version {requested_version} does not match manifest romSha1 "
            f"{rom_sha1} ({detected})")
    return detected or requested_version or "red"


def detect_rom_version(path):
    """Read a canonical ROM once and return its supported game version."""
    rom = RomImage(path, None)
    version = SHA1_TO_VERSION.get(rom.sha1)
    if version is None:
        expected = ", ".join(VERSION_SHA1[name] for name in VERSION_MANIFESTS)
        raise ValueError(
            f"unsupported ROM SHA-1 {rom.sha1}; expected one of {expected}")
    return version, rom


def extract_constants(manifest, out_dir):
    data = manifest["constants"]
    util.write_lua(
        os.path.join(out_dir, "constants.lua"), data,
        header="Source: canonical Pokemon Red ROM metadata manifest")
    return data


def _read_terminated(rom, bank, address, terminator, limit=256):
    out = []
    for offset in range(limit):
        value = rom.byte(bank, address + offset)
        if value == terminator:
            return out
        out.append(value)
    raise ValueError(
        f"unterminated byte list at {bank:02x}:{address:04x}")


def _decode_2bpp(raw, width, height, transparent_color0=False):
    if width % 8 or height % 8:
        raise ValueError(f"2bpp dimensions must be tile-aligned: {width}x{height}")
    tile_count = width // 8 * (height // 8)
    if len(raw) != tile_count * 16:
        raise ValueError(
            f"2bpp payload is {len(raw)} bytes, expected {tile_count * 16}")

    image = Image.new("RGBA", (width, height))
    pixels = image.load()
    tiles_per_row = width // 8
    for tile in range(tile_count):
        tile_x = (tile % tiles_per_row) * 8
        tile_y = (tile // tiles_per_row) * 8
        for y in range(8):
            low = raw[tile * 16 + y * 2]
            high = raw[tile * 16 + y * 2 + 1]
            for x in range(8):
                bit = 7 - x
                shade = ((high >> bit) & 1) * 2 + ((low >> bit) & 1)
                color = GB_SHADES[shade]
                if transparent_color0 and shade == 0:
                    color = (255, 255, 255, 0)
                pixels[tile_x + x, tile_y + y] = color
    return image


def _decode_1bpp(raw, width, height, transparent_color0=False):
    if width % 8 or height % 8:
        raise ValueError(f"1bpp dimensions must be tile-aligned: {width}x{height}")
    tile_count = width // 8 * (height // 8)
    if len(raw) != tile_count * 8:
        raise ValueError(
            f"1bpp payload is {len(raw)} bytes, expected {tile_count * 8}")

    image = Image.new("RGBA", (width, height))
    pixels = image.load()
    tiles_per_row = width // 8
    for tile in range(tile_count):
        tile_x = (tile % tiles_per_row) * 8
        tile_y = (tile // tiles_per_row) * 8
        for y in range(8):
            row = raw[tile * 8 + y]
            for x in range(8):
                filled = bool(row & (1 << (7 - x)))
                if filled:
                    color = (0, 0, 0, 255)
                elif transparent_color0:
                    color = (255, 255, 255, 0)
                else:
                    color = (255, 255, 255, 255)
                pixels[tile_x + x, tile_y + y] = color
    return image


def _columns_to_rows(raw, tiles_wide, tiles_high, bytes_per_tile=16):
    out = bytearray(len(raw))
    for y in range(tiles_high):
        for x in range(tiles_wide):
            source = (x * tiles_high + y) * bytes_per_tile
            target = (y * tiles_wide + x) * bytes_per_tile
            out[target:target + bytes_per_tile] = \
                raw[source:source + bytes_per_tile]
    return bytes(out)


def _save_png(image, path):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    image.save(path, optimize=True)


def _matte_color0(image):
    pixels = image.load()
    width, height = image.size
    queue = deque()
    seen = set()

    def add(x, y):
        if (x, y) not in seen and pixels[x, y] == (255, 255, 255, 255):
            seen.add((x, y))
            queue.append((x, y))

    for x in range(width):
        add(x, 0)
        add(x, height - 1)
    for y in range(height):
        add(0, y)
        add(width - 1, y)
    while queue:
        x, y = queue.popleft()
        pixels[x, y] = (255, 255, 255, 0)
        for next_x, next_y in (
                (x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
            if 0 <= next_x < width and 0 <= next_y < height:
                add(next_x, next_y)
    return image


def _write_2bpp_png(
        raw, width, height, path, transparent_color0=False):
    _save_png(
        _decode_2bpp(raw, width, height, transparent_color0),
        path)


def _write_compressed_pic(rom, symbols, label, path):
    symbol = _symbol(symbols, label)
    compressed = rom.bytes(
        symbol.bank, symbol.address, 0x8000 - symbol.address)
    raw, width = decompress_pic(compressed)
    image = _matte_color0(
        _decode_2bpp(raw, width * 8, width * 8))
    _save_png(image, path)
    return width


def extract_tilesets(rom, symbols, manifest, out_dir, assets_dir):
    order = manifest["constants"]["tilesetOrder"]
    metadata = manifest["tilesets"]
    animations = manifest["tileAnimations"]
    if len(metadata) != len(order):
        raise ValueError("tileset metadata count does not match constants")

    headers = _symbol(symbols, "Tilesets")
    warp_pointers = _symbol(symbols, "WarpTileIDPointers")
    door_pointers = _symbol(symbols, "DoorTileIDPointers")

    doors = {}
    address = door_pointers.address
    while True:
        tileset_id = rom.byte(door_pointers.bank, address)
        if tileset_id == 0xFF:
            break
        pointer = rom.word(door_pointers.bank, address + 1)
        doors[tileset_id] = _read_terminated(
            rom, door_pointers.bank, pointer, 0)
        address += 3

    out = {}
    written_images = set()
    for index, (const_name, spec) in enumerate(zip(order, metadata)):
        if spec["id"] != const_name:
            raise ValueError(
                f"tileset metadata {spec['id']} is out of order at {const_name}")
        row_address = headers.address + index * 12
        gfx_bank = rom.byte(headers.bank, row_address)
        block_pointer = rom.word(headers.bank, row_address + 1)
        gfx_pointer = rom.word(headers.bank, row_address + 3)
        collision_pointer = rom.word(headers.bank, row_address + 5)
        counters = list(rom.bytes(headers.bank, row_address + 7, 3))
        grass = rom.byte(headers.bank, row_address + 10)
        animation_id = rom.byte(headers.bank, row_address + 11)
        if animation_id >= len(animations):
            raise ValueError(
                f"{const_name}: unknown tile animation {animation_id}")

        blocks_raw = rom.bytes(
            gfx_bank, block_pointer, spec["blockCount"] * 16)
        blocks = [
            list(blocks_raw[offset:offset + 16])
            for offset in range(0, len(blocks_raw), 16)
        ]
        # Red/Blue keep collision lists in ROM0; Yellow moved them to bank 1
        # (pokeyellow Overworld_Coll at 01:4ac2). Pointers in $4000-$7FFF are
        # banked; treat ROM0-range pointers as bank 0.
        coll_bank = 0 if collision_pointer < 0x4000 else 1
        walkable = sorted(_read_terminated(
            rom, coll_bank, collision_pointer, 0xFF))
        warp_pointer = rom.word(
            warp_pointers.bank, warp_pointers.address + index * 2)
        warp_tiles = sorted(set(_read_terminated(
            rom, warp_pointers.bank, warp_pointer, 0xFF)))

        base = spec["imageBase"]
        image_path = os.path.join(assets_dir, "tilesets", base + ".png")
        if base not in written_images:
            byte_length = spec["imageWidth"] * spec["imageHeight"] // 4
            stored_length = block_pointer - gfx_pointer
            if stored_length < 0 or stored_length > byte_length \
                    or stored_length % 16:
                raise ValueError(
                    f"{const_name}: invalid stored tileset graphics length "
                    f"{stored_length}")
            pixels = rom.bytes(gfx_bank, gfx_pointer, stored_length)
            pixels += bytes(byte_length - stored_length)
            _write_2bpp_png(
                pixels,
                spec["imageWidth"], spec["imageHeight"], image_path)
            written_images.add(base)

        out[const_name] = {
            "id": const_name,
            "source": f"ROM:Tilesets[{index}]",
            "image": f"assets/generated/tilesets/{base}.png",
            "imageWidth": spec["imageWidth"],
            "imageHeight": spec["imageHeight"],
            "tilesPerRow": spec["imageWidth"] // 8,
            "blocks": blocks,
            "walkable": walkable,
            "counterTiles": [value for value in counters if value != 0xFF],
            "grassTile": None if grass == 0xFF else grass,
            "doorTiles": sorted(doors.get(index, [])),
            "warpTiles": warp_tiles,
            "animation": animations[animation_id],
        }

    for number in (1, 2, 3):
        symbol = _symbol(symbols, f"FlowerTile{number}")
        _write_2bpp_png(
            rom.bytes(symbol.bank, symbol.address, 16), 8, 8,
            os.path.join(assets_dir, "tilesets", f"flower{number}.png"))
    spinner = _symbol(symbols, "SpinnerArrowAnimTiles")
    _write_2bpp_png(
        rom.bytes(spinner.bank, spinner.address, 64), 32, 8,
        os.path.join(assets_dir, "tilesets", "spinners.png"))

    util.write_lua(
        os.path.join(out_dir, "tilesets.lua"), out,
        header="Source: canonical Pokemon Red ROM (Tilesets, blocksets,\n"
               "tile graphics, collision/warp/door lists)")
    return out


def extract_font(rom, symbols, manifest, out_dir, assets_dir):
    fonts_dir = os.path.join(assets_dir, "fonts")
    main_symbol = _symbol(symbols, "FontGraphics")
    main_raw = rom.bytes(main_symbol.bank, main_symbol.address, 128 * 8)
    main = Image.new("RGBA", (128, 64), (0, 0, 0, 0))
    pixels = main.load()
    for tile in range(128):
        tile_x = (tile % 16) * 8
        tile_y = (tile // 16) * 8
        for y in range(8):
            row = main_raw[tile * 8 + y]
            for x in range(8):
                if row & (1 << (7 - x)):
                    pixels[tile_x + x, tile_y + y] = (0, 0, 0, 255)
    _save_png(main, os.path.join(fonts_dir, "font.png"))

    extra_symbol = _symbol(symbols, "TextBoxGraphics")
    extra_raw = rom.bytes(extra_symbol.bank, extra_symbol.address, 32 * 16)
    extra_shaded = _decode_2bpp(extra_raw, 128, 16)
    extra = Image.new("RGBA", extra_shaded.size, (0, 0, 0, 0))
    source_pixels = extra_shaded.load()
    target_pixels = extra.load()
    for y in range(extra.height):
        for x in range(extra.width):
            if source_pixels[x, y][0] < 128:
                target_pixels[x, y] = (0, 0, 0, 255)

    pokedex = _symbol(symbols, "PokedexTileGraphics")
    dex_tiles = _decode_2bpp(
        rom.bytes(pokedex.bank, pokedex.address, 32), 16, 8)
    dex_pixels = dex_tiles.load()
    for y in range(8):
        for x in range(16):
            extra.putpixel(
                (x, y),
                (0, 0, 0, 255) if dex_pixels[x, y][0] < 128
                else (0, 0, 0, 0))
    _save_png(extra, os.path.join(fonts_dir, "font_extra.png"))

    data = {
        "source": "ROM:FontGraphics, TextBoxGraphics, PokedexTileGraphics",
        "image": "assets/generated/fonts/font.png",
        "imageExtra": "assets/generated/fonts/font_extra.png",
        "mainBase": 0x80,
        "extraBase": 0x60,
        "glyphsPerRow": 16,
        "charmap": manifest["fontCharmap"],
    }
    util.write_lua(
        os.path.join(out_dir, "font.lua"), data,
        header="Source: canonical Pokemon Red ROM font graphics;\n"
               "symbolic charmap comes from the metadata manifest")
    return data


def extract_sprites(rom, symbols, manifest, out_dir, assets_dir):
    order = manifest["constants"]["spriteOrder"]
    metadata = manifest["sprites"]["order"]
    table = _symbol(symbols, "SpriteSheetPointerTable")
    if len(metadata) != len(order):
        raise ValueError("sprite metadata count does not match constants")

    out = {}
    written = set()
    for index, (const_name, spec) in enumerate(zip(order, metadata)):
        if spec["id"] != const_name:
            raise ValueError(
                f"sprite metadata {spec['id']} is out of order at {const_name}")
        address = table.address + index * 4
        pointer = rom.word(table.bank, address)
        first_half_length = rom.byte(table.bank, address + 2)
        bank = rom.byte(table.bank, address + 3)
        width = spec["imageWidth"]
        height = spec["imageHeight"]
        byte_length = width * height // 4
        frames = height // 16
        expected_length = first_half_length * (2 if frames >= 6 else 1)
        if byte_length != expected_length:
            # Commercial ROM sheet length wins over pret PNG atlases (Yellow's
            # nurse.png is 16x64 but SpriteSheetPointerTable still stores 12
            # tiles / 192 bytes).
            byte_length = expected_length
            if byte_length * 4 % width:
                raise ValueError(
                    f"{const_name}: ROM sprite length {byte_length} is not "
                    f"tile-aligned for width {width}")
            height = byte_length * 4 // width
            frames = height // 16
            expected_length = first_half_length * (2 if frames >= 6 else 1)
            if byte_length != expected_length:
                raise ValueError(
                    f"{const_name}: ROM sprite length {expected_length} does not "
                    f"match atlas length {width * spec['imageHeight'] // 4}")

        base = spec["imageBase"]
        if base not in written:
            _write_2bpp_png(
                rom.bytes(bank, pointer, byte_length),
                width, height,
                os.path.join(assets_dir, "sprites", base + ".png"),
                transparent_color0=True)
            written.add(base)
        out[const_name] = {
            "id": const_name,
            "source": f"ROM:SpriteSheetPointerTable[{index}]",
            "image": f"assets/generated/sprites/{base}.png",
            "frames": frames,
            "walker": frames >= 6,
        }

    bike = manifest["sprites"]["bike"]
    bike_symbol = _symbol(symbols, bike["label"])
    bike_length = bike["imageWidth"] * bike["imageHeight"] // 4
    _write_2bpp_png(
        rom.bytes(bike_symbol.bank, bike_symbol.address, bike_length),
        bike["imageWidth"], bike["imageHeight"],
        os.path.join(assets_dir, "sprites", bike["imageBase"] + ".png"),
        transparent_color0=True)
    bike_frames = bike["imageHeight"] // 16
    out["SPRITE_RED_BIKE"] = {
        "id": "SPRITE_RED_BIKE",
        "source": "ROM:RedBikeSprite",
        "image": "assets/generated/sprites/red_bike.png",
        "frames": bike_frames,
        "walker": bike_frames >= 6,
    }

    # Yellow-only: the surfing-Pikachu overworld ride sheet, loaded
    # outside SpriteSheetPointerTable (LoadSurfingPlayerSpriteGraphics2) --
    # same extra extract as RedBikeSprite. (RFC 0001)
    surf = manifest["sprites"].get("surfPikachu")
    if surf and _has_symbol(symbols, surf["label"]):
        surf_symbol = _symbol(symbols, surf["label"])
        surf_length = surf["imageWidth"] * surf["imageHeight"] // 4
        _write_2bpp_png(
            rom.bytes(surf_symbol.bank, surf_symbol.address, surf_length),
            surf["imageWidth"], surf["imageHeight"],
            os.path.join(assets_dir, "sprites", surf["imageBase"] + ".png"),
            transparent_color0=True)
        surf_frames = surf["imageHeight"] // 16
        out["SPRITE_SURFING_PIKACHU"] = {
            "id": "SPRITE_SURFING_PIKACHU",
            "source": f"ROM:{surf['label']}",
            "image": f"assets/generated/sprites/{surf['imageBase']}.png",
            "frames": surf_frames,
            "walker": surf_frames >= 6,
        }

    util.write_lua(
        os.path.join(out_dir, "sprites.lua"), out,
        header="Source: canonical Pokemon Red ROM (overworld sprite sheets)")
    return out


def _signed_byte(value):
    return value - 0x100 if value & 0x80 else value


def _map_id(order, value):
    if value == 0xFF:
        return "LAST_MAP"
    if value >= len(order):
        raise ValueError(f"unknown map id ${value:02X}")
    return order[value]


def extract_maps(rom, symbols, manifest, out_dir):
    map_order = manifest["constants"]["mapOrder"]
    dimensions = manifest["constants"]["maps"]
    metadata = manifest["maps"]
    tilesets = manifest["constants"]["tilesetOrder"]
    sprites = manifest["constants"]["spriteOrder"]
    movement_names = {0xFE: "WALK", 0xFF: "STAY"}
    range_names = {
        0x00: "ANY_DIR",
        0x01: "UP_DOWN",
        0x02: "LEFT_RIGHT",
        0x10: "BOULDER_MOVEMENT_BYTE_2",
        0xD0: "DOWN",
        0xD1: "UP",
        0xD2: "LEFT",
        0xD3: "RIGHT",
        0xFF: "NONE",
    }
    directions = (
        ("north", 0x08),
        ("south", 0x04),
        ("west", 0x02),
        ("east", 0x01),
    )

    out = {}
    for const_name, spec in metadata.items():
        dims = dimensions[const_name]
        label = spec["label"]
        header = _symbol(symbols, label + "_h")
        address = header.address
        tileset_id = rom.byte(header.bank, address)
        height = rom.byte(header.bank, address + 1)
        width = rom.byte(header.bank, address + 2)
        if (width, height) != (dims["width"], dims["height"]):
            raise ValueError(
                f"{const_name}: ROM dimensions {width}x{height} do not match "
                f"manifest {dims['width']}x{dims['height']}")
        if tileset_id >= len(tilesets):
            raise ValueError(f"{const_name}: unknown tileset id {tileset_id}")
        block_pointer = rom.word(header.bank, address + 3)
        connection_flags = rom.byte(header.bank, address + 9)
        address += 10

        connections = {}
        for direction, bit in directions:
            if not connection_flags & bit:
                continue
            target_id = rom.byte(header.bank, address)
            y_offset = _signed_byte(rom.byte(header.bank, address + 7))
            x_offset = _signed_byte(rom.byte(header.bank, address + 8))
            encoded_offset = x_offset if direction in ("north", "south") \
                else y_offset
            if encoded_offset % 2:
                raise ValueError(
                    f"{const_name}: odd {direction} connection offset")
            connections[direction] = {
                "map": _map_id(map_order, target_id),
                "offset": -encoded_offset // 2,
            }
            address += 11
        if connection_flags & ~0x0F:
            raise ValueError(
                f"{const_name}: unknown connection flags ${connection_flags:02X}")
        object_pointer = rom.word(header.bank, address)

        object_address = object_pointer
        border_block = rom.byte(header.bank, object_address)
        object_address += 1

        warp_count = rom.byte(header.bank, object_address)
        object_address += 1
        warps = []
        for _ in range(warp_count):
            y, x, dest_warp, dest_map = rom.bytes(
                header.bank, object_address, 4)
            warps.append({
                "x": x,
                "y": y,
                "destMap": _map_id(map_order, dest_map),
                "destWarp": dest_warp + 1,
            })
            object_address += 4

        sign_count = rom.byte(header.bank, object_address)
        object_address += 1
        if sign_count != len(spec["signTexts"]):
            raise ValueError(
                f"{const_name}: ROM has {sign_count} signs, metadata has "
                f"{len(spec['signTexts'])}")
        signs = []
        for sign_text in spec["signTexts"]:
            y, x, _text_id = rom.bytes(header.bank, object_address, 3)
            signs.append({"x": x, "y": y, "text": sign_text})
            object_address += 3

        object_count = rom.byte(header.bank, object_address)
        object_address += 1
        if object_count != len(spec["objects"]):
            raise ValueError(
                f"{const_name}: ROM has {object_count} objects, metadata has "
                f"{len(spec['objects'])}")
        objects = []
        for index, object_spec in enumerate(spec["objects"], start=1):
            sprite_id, y, x, movement_id, range_id, text_id = rom.bytes(
                header.bank, object_address, 6)
            if not 1 <= sprite_id <= len(sprites):
                raise ValueError(
                    f"{const_name} object {index}: unknown sprite {sprite_id}")
            if movement_id not in movement_names or range_id not in range_names:
                raise ValueError(
                    f"{const_name} object {index}: unknown movement encoding")
            obj = {
                "index": index,
                "x": x - 4,
                "y": y - 4,
                "sprite": sprites[sprite_id - 1],
                "movement": movement_names[movement_id],
                "range": range_names[range_id],
                "text": object_spec["text"],
            }
            object_address += 6

            if text_id & 0x80:
                if "item" not in object_spec:
                    raise ValueError(
                        f"{const_name} object {index}: unexpected item payload")
                obj["item"] = object_spec["item"]
                object_address += 1
            elif text_id & 0x40:
                extra, level_or_party = rom.bytes(
                    header.bank, object_address, 2)
                object_address += 2
                if "trainerClass" in object_spec:
                    obj["trainerClass"] = object_spec["trainerClass"]
                    party = object_spec.get("trainerParty")
                    obj["trainerParty"] = (
                        party if isinstance(party, str) else level_or_party)
                elif "pokemon" in object_spec:
                    obj["pokemon"] = object_spec["pokemon"]
                    obj["level"] = level_or_party
                else:
                    raise ValueError(
                        f"{const_name} object {index}: unexpected trainer "
                        "or static Pokemon payload")
                _ = extra
            elif any(key in object_spec for key in (
                    "item", "trainerClass", "pokemon")):
                raise ValueError(
                    f"{const_name} object {index}: missing extra payload")

            for key in ("name", "hidden"):
                if key in object_spec:
                    obj[key] = object_spec[key]
            objects.append(obj)

        expected_blocks = width * height
        block_length = spec["blockLength"]
        if block_length > expected_blocks:
            raise ValueError(
                f"{const_name}: block payload is longer than map dimensions")
        blocks = list(rom.bytes(
            header.bank, block_pointer, block_length))
        blocks.extend([border_block] * (expected_blocks - block_length))

        out[const_name] = {
            "id": const_name,
            "label": label,
            "index": dims["index"],
            "source": f"ROM:{header.bank:02X}:{header.address:04X}",
            "tileset": tilesets[tileset_id],
            "width": width,
            "height": height,
            "blocks": blocks,
            "borderBlock": border_block,
            "connections": connections,
            "warps": warps,
            "signs": signs,
            "objects": objects,
        }

    util.write_lua(
        os.path.join(out_dir, "maps.lua"), out,
        header="Source: canonical Pokemon Red ROM (map headers, block maps,\n"
               "connections, warps, signs, and object events)")
    return out


def _animation_flags(rom, symbols, count):
    table = _symbol(symbols, "AttackAnimationPointers")
    flags = []
    for index in range(count):
        address = rom.word(table.bank, table.address + index * 2)
        shake = False
        flash = False
        for _ in range(256):
            first = rom.byte(table.bank, address)
            if first == 0xFF:
                break
            if first >= 0xD8:
                shake = shake or first == 0xFB
                flash = flash or first in (0xF8, 0xFE)
                address += 2
            else:
                address += 3
        else:
            raise ValueError(f"unterminated move animation {index + 1}")
        flags.append((shake, flash))
    return flags


def extract_moves(rom, symbols, manifest, out_dir):
    order = manifest["constants"]["moveOrder"]
    type_by_id = {
        int(value): name
        for name, value in manifest["constants"]["types"].items()
    }
    effects = manifest["moveEffects"]
    charmap = manifest["charmap"]
    sfx_keys = manifest["sfxKeys"]

    moves = _symbol(symbols, "Moves")
    names = _symbol(symbols, "MoveNames")
    sounds = _symbol(symbols, "MoveSoundTable")
    flags = _animation_flags(rom, symbols, len(order))

    decoded_names = []
    address = names.address
    for _ in order:
        value, consumed = read_string(
            rom, names.bank, address, charmap, max_length=32)
        decoded_names.append(value)
        address += consumed

    out = {}
    for index, move_id in enumerate(order):
        row = rom.bytes(moves.bank, moves.address + index * 6, 6)
        if row[0] != index + 1:
            raise ValueError(
                f"Moves row {index + 1} stores animation id {row[0]}")
        effect = effects[row[1]] if row[1] < len(effects) else f"EFFECT_{row[1]:02X}"
        type_name = type_by_id.get(row[3], f"TYPE_{row[3]:02X}")
        sound_id, pitch, tempo = rom.bytes(
            sounds.bank, sounds.address + index * 3, 3)
        anim = {
            "sound": sfx_keys.get(str(sound_id), f"SFX_{sound_id:02X}"),
            "pitch": pitch,
            "tempo": tempo,
        }
        shake, flash = flags[index]
        if shake:
            anim["shake"] = True
        if flash:
            anim["flash"] = True
        out[move_id] = {
            "id": move_id,
            "index": index + 1,
            "name": decoded_names[index],
            "source": f"ROM:Moves[{index + 1}]",
            "effect": effect,
            "power": row[2],
            "type": type_name,
            "accuracy": round(row[4] * 100 / 255),
            "pp": row[5],
            "anim": anim,
        }

    util.write_lua(
        os.path.join(out_dir, "moves.lua"), out,
        header="Source: canonical Pokemon Red ROM (Moves, MoveNames,\n"
               "MoveSoundTable, AttackAnimationPointers)")
    return out


def extract_battle_anims(
        rom, symbols, manifest, out_dir, assets_dir):
    metadata = manifest["battleAnimations"]
    move_order = manifest["constants"]["moveOrder"]
    if len(move_order) != metadata["moveCount"]:
        raise ValueError("battle animation move count does not match constants")

    coords_symbol = _symbol(symbols, "FrameBlockBaseCoords")
    base_coords = {}
    for index in range(metadata["baseCoordCount"]):
        y, x = rom.bytes(
            coords_symbol.bank, coords_symbol.address + index * 2, 2)
        base_coords[index] = {"y": y, "x": x}

    blocks_symbol = _symbol(symbols, "FrameBlockPointers")
    frame_blocks = {}
    for index in range(metadata["frameBlockCount"]):
        address = rom.word(
            blocks_symbol.bank, blocks_symbol.address + index * 2)
        count = rom.byte(blocks_symbol.bank, address)
        address += 1
        entries = []
        for _ in range(count):
            y, x, tile, attrs = rom.bytes(
                blocks_symbol.bank, address, 4)
            entry = {
                "y": y,
                "x": x,
                "tile": tile,
                "xflip": bool(attrs & 0x20),
                "yflip": bool(attrs & 0x40),
            }
            if attrs & 0x80:
                entry["prio"] = True
            if attrs & 0x10:
                entry["pal1"] = True
            entries.append(entry)
            address += 4
        frame_blocks[index] = entries

    subanim_symbol = _symbol(symbols, "SubanimationPointers")
    subanims = {}
    type_names = metadata["subanimTypes"]
    for index in range(metadata["subanimCount"]):
        address = rom.word(
            subanim_symbol.bank, subanim_symbol.address + index * 2)
        packed = rom.byte(subanim_symbol.bank, address)
        type_id, count = packed >> 5, packed & 0x1F
        if type_id >= len(type_names):
            raise ValueError(
                f"subanimation {index} has unknown type {type_id}")
        address += 1
        entries = []
        for _ in range(count):
            block, coord, mode = rom.bytes(
                subanim_symbol.bank, address, 3)
            if block >= metadata["frameBlockCount"]:
                raise ValueError(
                    f"subanimation {index} has invalid frame block {block}")
            if coord >= metadata["baseCoordCount"]:
                raise ValueError(
                    f"subanimation {index} has invalid base coord {coord}")
            entries.append({
                "block": block, "coord": coord, "mode": mode})
            address += 3
        subanims[index] = {
            "type": type_names[type_id],
            "blocks": entries,
        }

    tiles_table = _symbol(symbols, "MoveAnimationTilesPointers")
    tile_rows = []
    tile_specs = metadata["tilesheets"]
    if len(tile_specs) != 3:
        raise ValueError("expected three battle animation tilesheets")
    for index, spec in enumerate(tile_specs):
        count, low, high, padding = rom.bytes(
            tiles_table.bank, tiles_table.address + index * 4, 4)
        if padding != 0xFF:
            raise ValueError(
                f"battle animation tilesheet {index} has invalid padding")
        pointer = low | high << 8
        expected = _symbol(symbols, f"MoveAnimationTiles{index}")
        if expected.bank != tiles_table.bank or expected.address != pointer:
            raise ValueError(
                f"battle animation tilesheet {index} pointer differs")
        tile_rows.append({
            "count": count, "pointer": pointer, "spec": spec})

    image_payloads = {}
    for row in tile_rows:
        path = row["spec"]["path"]
        existing = image_payloads.get(path)
        if existing and existing["pointer"] != row["pointer"]:
            raise ValueError(
                f"shared battle animation atlas {path} has two pointers")
        if not existing:
            existing = {"pointer": row["pointer"], "tiles": 0,
                        "spec": row["spec"]}
            image_payloads[path] = existing
        existing["tiles"] = max(existing["tiles"], row["count"])

    for path, payload in image_payloads.items():
        spec = payload["spec"]
        byte_length = spec["width"] * spec["height"] // 4
        stored_length = payload["tiles"] * 16
        if stored_length > byte_length:
            raise ValueError(f"{path}: battle animation atlas is too large")
        raw = rom.bytes(
            tiles_table.bank, payload["pointer"], stored_length)
        raw += bytes(byte_length - stored_length)
        prefix = "assets/generated/"
        if not path.startswith(prefix):
            raise ValueError(f"invalid generated asset path {path!r}")
        _write_2bpp_png(
            raw, spec["width"], spec["height"],
            os.path.join(assets_dir, path[len(prefix):]),
            transparent_color0=True)

    tilesheets = {}
    for index, row in enumerate(tile_rows):
        spec = row["spec"]
        tilesheets[index] = {
            "path": spec["path"],
            "width": spec["width"],
            "height": spec["height"],
            "tiles": row["count"],
            "source": spec["source"],
        }

    move_names = move_order + metadata["miscAnimations"]
    pointer_table = _symbol(symbols, "AttackAnimationPointers")
    first_special = metadata["firstSpecialEffect"]
    special_effects = metadata["specialEffects"]
    move_anims = {}
    for index, name in enumerate(move_names):
        address = rom.word(
            pointer_table.bank, pointer_table.address + index * 2)
        sequence = []
        for _ in range(256):
            first = rom.byte(pointer_table.bank, address)
            if first == 0xFF:
                break
            sound = rom.byte(pointer_table.bank, address + 1)
            if first >= first_special:
                effect = special_effects.get(str(first))
                if not effect:
                    raise ValueError(
                        f"{name}: unknown special effect ${first:02X}")
                row = {"effect": effect}
                address += 2
            else:
                subanim = rom.byte(pointer_table.bank, address + 2)
                delay = first & 0x3F
                tileset = first >> 6
                if not delay:
                    raise ValueError(f"{name}: zero animation delay")
                if subanim >= metadata["subanimCount"]:
                    raise ValueError(
                        f"{name}: unknown subanimation {subanim}")
                if tileset not in tilesheets:
                    raise ValueError(
                        f"{name}: unknown animation tileset {tileset}")
                row = {
                    "subanim": subanim,
                    "tileset": tileset,
                    "delay": delay,
                }
                address += 3
            if sound != 0xFF:
                if sound >= len(move_order):
                    raise ValueError(
                        f"{name}: unknown animation sound {sound}")
                row["sound"] = move_order[sound]
            sequence.append(row)
        else:
            raise ValueError(f"{name}: unterminated battle animation")
        move_anims[name] = {
            "source": f"ROM:AttackAnimationPointers[{index}]",
            "seq": sequence,
        }

    for name, anim in move_anims.items():
        for row in anim["seq"]:
            if "subanim" not in row:
                continue
            sheet = tilesheets[row["tileset"]]
            for block_ref in subanims[row["subanim"]]["blocks"]:
                for tile in frame_blocks[block_ref["block"]]:
                    if tile["tile"] >= sheet["tiles"]:
                        raise ValueError(
                            f"{name}: tile {tile['tile']} is out of range "
                            f"for tileset {row['tileset']}")

    out = {
        "tilesheets": tilesheets,
        "baseCoords": base_coords,
        "frameBlocks": frame_blocks,
        "subanims": subanims,
        "moveAnims": move_anims,
    }
    util.write_lua(
        os.path.join(out_dir, "battle_anims.lua"), out,
        header="Source: canonical Pokemon Red ROM battle animation tables,\n"
               "frame geometry, coordinates, and OAM tile graphics")
    return out


def _nybbles(raw, count):
    out = []
    for value in raw:
        out.extend((value >> 4, value & 0x0F))
    return out[:count]


def extract_items(rom, symbols, manifest, out_dir):
    order = manifest["items"]
    charmap = manifest["charmap"]
    names = _symbol(symbols, "ItemNames")
    prices = _symbol(symbols, "ItemPrices")
    key_flags = _symbol(symbols, "KeyItemFlags")
    tm_prices = _symbol(symbols, "TechnicalMachinePrices")

    decoded_names = []
    address = names.address
    for _ in order:
        value, consumed = read_string(
            rom, names.bank, address, charmap, max_length=32)
        decoded_names.append(value)
        address += consumed

    num_items = manifest["numItems"]
    flags = rom.bytes(key_flags.bank, key_flags.address, (num_items + 7) // 8)
    out = {}
    for index, item_id in enumerate(order):
        entry = {
            "id": item_id,
            "index": index + 1,
            "name": decoded_names[index],
            "price": bcd(rom.bytes(
                prices.bank, prices.address + index * 3, 3)),
            "source": f"ROM:ItemNames[{index + 1}]",
        }
        if index < num_items and flags[index // 8] & (1 << (index % 8)):
            entry["keyItem"] = True
        out[item_id] = entry

    for number, move in enumerate(manifest["hms"], start=1):
        item_id = "HM_" + move
        out[item_id] = {
            "id": item_id,
            "name": f"HM{number:02d}",
            "price": 0,
            "machine": {"kind": "HM", "number": number, "move": move},
            "source": "ROM metadata manifest (HM mapping)",
        }

    tms = manifest["tms"]
    packed = rom.bytes(
        tm_prices.bank, tm_prices.address, (len(tms) + 1) // 2)
    prices_by_tm = _nybbles(packed, len(tms))
    for number, move in enumerate(tms, start=1):
        item_id = "TM_" + move
        out[item_id] = {
            "id": item_id,
            "name": f"TM{number:02d}",
            "price": prices_by_tm[number - 1] * 1000,
            "machine": {"kind": "TM", "number": number, "move": move},
            "source": f"ROM:TechnicalMachinePrices[{number}]",
        }

    util.write_lua(
        os.path.join(out_dir, "items.lua"), out,
        header="Source: canonical Pokemon Red ROM (ItemNames, ItemPrices,\n"
               "KeyItemFlags, TechnicalMachinePrices)")
    return out


def extract_type_chart(rom, symbols, manifest, out_dir):
    type_by_id = {
        int(value): name
        for name, value in manifest["constants"]["types"].items()
    }
    effects = _symbol(symbols, "TypeEffects")
    address = effects.address
    matchups = []
    while rom.byte(effects.bank, address) != 0xFF:
        attacker, defender, multiplier = rom.bytes(
            effects.bank, address, 3)
        matchups.append({
            "attacker": type_by_id.get(attacker, f"TYPE_{attacker:02X}"),
            "defender": type_by_id.get(defender, f"TYPE_{defender:02X}"),
            "multiplier": multiplier,
        })
        address += 3

    names = []
    seen = set()
    for label in manifest["typeNameLabels"]:
        symbol = _symbol(symbols, label)
        if (symbol.bank, symbol.address) in seen:
            continue
        seen.add((symbol.bank, symbol.address))
        name, _ = read_string(
            rom, symbol.bank, symbol.address, manifest["charmap"],
            max_length=16)
        names.append(name)

    data = {
        "source": "ROM:TypeEffects + TypeNames",
        "matchups": matchups,
        "names": names,
    }
    util.write_lua(
        os.path.join(out_dir, "type_chart.lua"), data,
        header="Source: canonical Pokemon Red ROM; multipliers are x10")
    return data


def _scale5(value):
    return round(value * 255 / 31)


def extract_palettes(rom, symbols, manifest, out_dir):
    order = manifest["paletteOrder"]
    table = _symbol(symbols, "SuperPalettes")
    palettes = {}
    for index, name in enumerate(order):
        colors = []
        for color in range(4):
            value = rom.word(
                table.bank, table.address + index * 8 + color * 2)
            colors.append([
                _scale5(value & 0x1F),
                _scale5((value >> 5) & 0x1F),
                _scale5((value >> 10) & 0x1F),
            ])
        palettes[name] = colors

    mon_table = _symbol(symbols, "MonsterPalettes")
    mon_pals = {}
    for index, species in enumerate(manifest["dexOrder"], start=1):
        palette_id = rom.byte(mon_table.bank, mon_table.address + index)
        mon_pals[species] = order[palette_id]

    data = {
        "source": "ROM:SuperPalettes + MonsterPalettes",
        "palettes": palettes,
        "order": order,
        "pokemon": mon_pals,
    }
    if _has_symbol(symbols, "CGBBasePalettes"):
        cgb_table = _symbol(symbols, "CGBBasePalettes")
        cgb = {}
        for index, name in enumerate(order):
            colors = []
            for color in range(4):
                value = rom.word(
                    cgb_table.bank, cgb_table.address + index * 8 + color * 2)
                colors.append([
                    _scale5(value & 0x1F),
                    _scale5((value >> 5) & 0x1F),
                    _scale5((value >> 10) & 0x1F),
                ])
            cgb[name] = colors
        data["cgbBase"] = cgb
        data["source"] = data["source"] + " + CGBBasePalettes"
    util.write_lua(
        os.path.join(out_dir, "palettes.lua"), data,
        header="Source: canonical Gen-1 ROM; 4 RGB colors per palette")
    return data


def extract_icons(rom, symbols, manifest, out_dir, assets_dir):
    table = _symbol(symbols, "MonPartyData")
    count = len(manifest["dexOrder"])
    packed = rom.bytes(table.bank, table.address, (count + 1) // 2)
    values = _nybbles(packed, count)
    by_dex = [
        manifest["iconOrder"][value]
        if value < len(manifest["iconOrder"]) else f"ICON_{value:X}"
        for value in values
    ]
    icons = {
        "MON": "assets/generated/sprites/monster.png",
        "BALL": "assets/generated/sprites/poke_ball.png",
        "HELIX": "assets/generated/sprites/fossil.png",
        "FAIRY": "assets/generated/sprites/fairy.png",
        "BIRD": "assets/generated/sprites/bird.png",
        "WATER": "assets/generated/sprites/seel.png",
        "BUG": "assets/generated/icons/bug.png",
        "GRASS": "assets/generated/icons/plant.png",
        "SNAKE": "assets/generated/icons/snake.png",
        "QUADRUPED": "assets/generated/icons/quadruped.png",
    }
    icon_frames = {
        "bug": ("BugIconFrame1", "BugIconFrame2"),
        "plant": ("PlantIconFrame1", "PlantIconFrame2"),
        "snake": ("SnakeIconFrame1", "SnakeIconFrame2"),
        "quadruped": ("QuadrupedIconFrame1", "QuadrupedIconFrame2"),
    }
    for filename, labels in icon_frames.items():
        raw = bytearray()
        for label in labels:
            symbol = _symbol(symbols, label)
            raw.extend(rom.bytes(symbol.bank, symbol.address, 32))
        half = _decode_2bpp(bytes(raw), 8, 32, transparent_color0=True)
        image = Image.new("RGBA", (16, 32), (255, 255, 255, 0))
        for frame in range(2):
            crop = half.crop((0, frame * 16, 8, frame * 16 + 16))
            image.paste(crop, (0, frame * 16))
            image.paste(
                crop.transpose(Image.Transpose.FLIP_LEFT_RIGHT),
                (8, frame * 16))
        _save_png(
            image, os.path.join(assets_dir, "icons", filename + ".png"))

    data = {
        "source": "ROM:MonPartyData",
        "byDex": by_dex,
        "icons": icons,
    }
    util.write_lua(
        os.path.join(out_dir, "icons.lua"), data,
        header="Source: canonical Pokemon Red ROM (MonPartyData)")
    return data


def _species(manifest, value):
    order = manifest["constants"]["speciesOrder"]
    if not 1 <= value <= len(order):
        return f"SPECIES_{value:02X}"
    return order[value - 1]


def _item(manifest, value):
    order = manifest["items"]
    if not 1 <= value <= len(order):
        return f"ITEM_{value:02X}"
    return order[value - 1]


def _move(manifest, value):
    order = manifest["constants"]["moveOrder"]
    if value == 0:
        return None
    if not 1 <= value <= len(order):
        return f"MOVE_{value:02X}"
    return order[value - 1]


def _types_by_id(manifest):
    return {
        int(value): name
        for name, value in manifest["constants"]["types"].items()
    }


def _decode_evos_moves(rom, symbols, manifest, index):
    table = _symbol(symbols, "EvosMovesPointerTable")
    address = rom.word(table.bank, table.address + index * 2)
    evolutions = []
    while True:
        method = rom.byte(table.bank, address)
        address += 1
        if method == 0:
            break
        if method == 1:
            level, species = rom.bytes(table.bank, address, 2)
            address += 2
            evolutions.append({
                "method": "LEVEL",
                "level": level,
                "species": _species(manifest, species),
            })
        elif method == 2:
            item, level, species = rom.bytes(table.bank, address, 3)
            address += 3
            evolutions.append({
                "method": "ITEM",
                "item": _item(manifest, item),
                "level": level,
                "species": _species(manifest, species),
            })
        elif method == 3:
            level, species = rom.bytes(table.bank, address, 2)
            address += 2
            evolutions.append({
                "method": "TRADE",
                "level": level,
                "species": _species(manifest, species),
            })
        else:
            raise ValueError(
                f"unknown evolution method {method} for species index {index + 1}")

    learnset = []
    while True:
        level = rom.byte(table.bank, address)
        address += 1
        if level == 0:
            break
        move = rom.byte(table.bank, address)
        address += 1
        learnset.append({"level": level, "move": _move(manifest, move)})
    return evolutions, learnset


def _dex_entry(rom, symbols, manifest, index, species):
    table = _symbol(symbols, "PokedexEntryPointers")
    address = rom.word(table.bank, table.address + index * 2)
    kind, consumed = read_string(
        rom, table.bank, address, manifest["charmap"], max_length=32)
    address += consumed
    height_ft, height_in = rom.bytes(table.bank, address, 2)
    weight = rom.word(table.bank, address + 2)
    address += 4
    if rom.byte(table.bank, address) != 0x17:
        raise ValueError(
            f"dex entry {index + 1} has no TX_FAR command")
    text_address = rom.word(table.bank, address + 1)
    text_bank = rom.byte(table.bank, address + 3)
    text_label = manifest["dexEntryLabels"].get(species)
    if text_label is None:
        text_label = f"_DexEntry_{text_bank:02X}_{text_address:04X}"
    return {
        "kind": kind,
        "heightFt": height_ft,
        "heightIn": height_in,
        "weight": weight,
        "text": text_label,
    }


def extract_pokemon(rom, symbols, manifest, out_dir, assets_dir):
    species_order = manifest["constants"]["speciesOrder"]
    dex_order = manifest["dexOrder"]
    dex_by_species = {
        species: index for index, species in enumerate(dex_order, start=1)
    }
    type_by_id = _types_by_id(manifest)
    names = _symbol(symbols, "MonsterNames")
    base_stats = _symbol(symbols, "BaseStats")
    # Red/Blue keep Mew outside BaseStats at MewBaseStats; Yellow folds Mew
    # into BaseStats at dex 151 (pret/pokeyellow), so the symbol is optional.
    mew_stats = symbols.by_name.get("MewBaseStats")

    decoded_names = []
    for index in range(len(species_order)):
        raw = rom.bytes(names.bank, names.address + index * 10, 10)
        decoded_names.append(
            decode_text(raw, manifest["charmap"]))

    out = {}
    written_front = set()
    written_back = set()
    for index, species in enumerate(species_order):
        if species.startswith(
                ("MISSINGNO", "UNUSED", "FOSSIL_", "MON_GHOST")):
            continue
        dex = dex_by_species[species]
        if species == "MEW" and mew_stats is not None:
            row = rom.bytes(mew_stats.bank, mew_stats.address, 28)
        else:
            row = rom.bytes(
                base_stats.bank, base_stats.address + (dex - 1) * 28, 28)
        if row[0] != dex:
            raise ValueError(
                f"{species} base stats store dex number {row[0]}, expected {dex}")

        level1_moves = [
            _move(manifest, value) for value in row[15:19] if value
        ]
        tmhm = []
        for bit, move in enumerate(manifest["tmhmMoves"]):
            if row[20 + bit // 8] & (1 << (bit % 8)):
                tmhm.append(move)
        evolutions, learnset = _decode_evos_moves(
            rom, symbols, manifest, index)
        asset = manifest["pokemonAssets"][species]
        front = asset["front"]
        back = asset["back"]
        if front and front not in written_front:
            decoded_size = _write_compressed_pic(
                rom, symbols, asset["frontLabel"],
                os.path.join(
                    assets_dir, "battle", "front", front + ".png"))
            if decoded_size != row[10] >> 4:
                raise ValueError(
                    f"{species}: front picture size {decoded_size} does not "
                    f"match base stats {row[10] >> 4}")
            written_front.add(front)
        if back and back not in written_back:
            _write_compressed_pic(
                rom, symbols, asset["backLabel"],
                os.path.join(
                    assets_dir, "battle", "back", back + ".png"))
            written_back.add(back)
        out[species] = {
            "id": species,
            "index": index + 1,
            "dex": dex,
            "name": decoded_names[index],
            "source": f"ROM:BaseStats[{dex}]",
            "types": list(dict.fromkeys(
                type_by_id.get(value, f"TYPE_{value:02X}")
                for value in row[6:8])),
            "baseStats": {
                "hp": row[1],
                "attack": row[2],
                "defense": row[3],
                "speed": row[4],
                "special": row[5],
            },
            "catchRate": row[8],
            "baseExp": row[9],
            "level1Moves": level1_moves,
            "growthRate": manifest["growthRates"][row[19]],
            "tmhm": tmhm,
            "learnset": learnset,
            "evolutions": evolutions,
            "spriteFront": (
                f"assets/generated/battle/front/{front}.png"
                if front else None),
            "spriteBack": (
                f"assets/generated/battle/back/{back}.png"
                if back else None),
            "frontSize": row[10] >> 4,
            "dexEntry": _dex_entry(
                rom, symbols, manifest, index, species),
        }

    for label, filename in (
            ("FossilAerodactylPic", "fossilaerodactyl"),
            ("FossilKabutopsPic", "fossilkabutops"),
            ("GhostPic", "ghost")):
        _write_compressed_pic(
            rom, symbols, label,
            os.path.join(
                assets_dir, "battle", "front", filename + ".png"))
    for label, filename in (
            ("RedPicBack", "redb"),
            ("OldManPicBack", "oldmanb")):
        _write_compressed_pic(
            rom, symbols, label,
            os.path.join(assets_dir, "battle", filename + ".png"))

    balls = _symbol(symbols, "PokeballTileGraphics")
    _write_2bpp_png(
        rom.bytes(balls.bank, balls.address, 64), 32, 8,
        os.path.join(assets_dir, "battle", "balls.png"),
        transparent_color0=True)

    trainer_card = (
        ("TrainerInfoTextBoxTileGraphics", "trainer_info.png", 24, 24, False),
        ("GymLeaderFaceAndBadgeTileGraphics", "badges.png", 16, 256, True),
        ("BadgeNumbersTileGraphics", "badge_numbers.png", 16, 32, True),
        ("CircleTile", "circle_tile.png", 8, 8, True),
    )
    for label, filename, width, height, transparent in trainer_card:
        symbol = _symbol(symbols, label)
        length = width * height // 4
        _write_2bpp_png(
            rom.bytes(symbol.bank, symbol.address, length), width, height,
            os.path.join(assets_dir, "trainer_card", filename),
            transparent_color0=transparent)
    _write_compressed_pic(
        rom, symbols, "RedPicFront",
        os.path.join(assets_dir, "trainer_card", "red.png"))

    util.write_lua(
        os.path.join(out_dir, "pokemon.lua"), out,
        header="Source: canonical Pokemon Red ROM (BaseStats, MonsterNames,\n"
               "EvosMovesPointerTable, PokedexEntryPointers)")
    return out


def _trainer_parties(rom, bank, start, end, manifest):
    parties = []
    address = start
    while address < end:
        first = rom.byte(bank, address)
        address += 1
        party = []
        if first == 0xFF:
            while True:
                level = rom.byte(bank, address)
                address += 1
                if level == 0:
                    break
                species = rom.byte(bank, address)
                address += 1
                party.append({
                    "level": level,
                    "species": _species(manifest, species),
                })
        else:
            level = first
            while True:
                species = rom.byte(bank, address)
                address += 1
                if species == 0:
                    break
                party.append({
                    "level": level,
                    "species": _species(manifest, species),
                })
        parties.append(party)
    if address != end:
        raise ValueError(
            f"trainer party data overran {bank:02X}:{end:04X}")
    return parties


def extract_trainers(rom, symbols, manifest, out_dir, assets_dir):
    order = manifest["trainers"]
    charmap = manifest["charmap"]
    names = _symbol(symbols, "TrainerNames")
    pointers = _symbol(symbols, "TrainerDataPointers")
    money = _symbol(symbols, "TrainerPicAndMoneyPointers")
    choices = _symbol(symbols, "TrainerClassMoveChoiceModifications")

    decoded_names = []
    address = names.address
    for _ in order:
        name, consumed = read_string(
            rom, names.bank, address, charmap, max_length=32)
        decoded_names.append(name)
        address += consumed

    ai_mods = []
    address = choices.address
    for _ in order:
        mods = []
        while True:
            value = rom.byte(choices.bank, address)
            address += 1
            if value == 0:
                break
            mods.append(value)
        ai_mods.append(mods)

    party_starts = [
        rom.word(pointers.bank, pointers.address + index * 2)
        for index in range(len(order))
    ]
    party_ends = party_starts[1:] + [_symbol(symbols, "TrainerAI").address]

    out = {}
    written_pics = set()
    for index, label in enumerate(order):
        trainer_id = "OPP_" + label
        raw_money = rom.bytes(
            money.bank, money.address + index * 5 + 2, 3)
        pic = manifest["trainerPics"][index]
        if pic and pic["imageBase"] not in written_pics:
            _write_compressed_pic(
                rom, symbols, pic["label"],
                os.path.join(
                    assets_dir, "battle", "trainers",
                    pic["imageBase"] + ".png"))
            written_pics.add(pic["imageBase"])
        out[trainer_id] = {
            "id": trainer_id,
            "index": index + 1,
            "name": decoded_names[index],
            "source": "ROM:TrainerDataPointers",
            "pic": pic["path"] if pic else None,
            "baseMoney": bcd(raw_money) // 100,
            "aiMods": ai_mods[index],
            "parties": _trainer_parties(
                rom, pointers.bank, party_starts[index],
                party_ends[index], manifest),
        }

    util.write_lua(
        os.path.join(out_dir, "trainers.lua"), out,
        header="Source: canonical Pokemon Red ROM (TrainerDataPointers,\n"
               "TrainerNames, TrainerPicAndMoneyPointers)")
    return out


def _wild_table(rom, bank, address, manifest):
    grass_rate = rom.byte(bank, address)
    address += 1
    grass = {"rate": grass_rate, "slots": []}
    if grass_rate:
        for _ in range(10):
            level, species = rom.bytes(bank, address, 2)
            grass["slots"].append({
                "level": level,
                "species": _species(manifest, species),
            })
            address += 2

    water_rate = rom.byte(bank, address)
    address += 1
    water = {"rate": water_rate, "slots": []}
    if water_rate:
        for _ in range(10):
            level, species = rom.bytes(bank, address, 2)
            water["slots"].append({
                "level": level,
                "species": _species(manifest, species),
            })
            address += 2
    return grass, water


def extract_encounters(rom, symbols, manifest, out_dir):
    maps = manifest["constants"]["mapOrder"]
    pointers = _symbol(symbols, "WildDataPointers")
    nothing = _symbol(symbols, "NothingWildMons")
    out = {}
    for index, map_id in enumerate(maps):
        address = rom.word(
            pointers.bank, pointers.address + index * 2)
        if address == nothing.address:
            continue
        grass, water = _wild_table(
            rom, pointers.bank, address, manifest)
        entry = {"source": f"ROM:{pointers.bank:02X}:{address:04X}"}
        if grass["rate"] or grass["slots"]:
            entry["grass"] = grass
        if water["rate"] or water["slots"]:
            entry["water"] = water
        out[map_id] = entry

    util.write_lua(
        os.path.join(out_dir, "encounters.lua"), out,
        header="Source: canonical Pokemon Red ROM (WildDataPointers)")
    return out


TEXT_GLYPH_OVERRIDES = {
    0x4B: "{_CONT}",
    0x4C: "{SCROLL}",
    0x6D: "{COLON}",
    0xF0: "¥",
}


def _text_glyph(value, charmap):
    if value in TEXT_GLYPH_OVERRIDES:
        return TEXT_GLYPH_OVERRIDES[value]
    glyph = charmap.get(str(value), f"{{BYTE:{value:02X}}}")
    if glyph.startswith("<") and glyph.endswith(">"):
        return "{" + glyph[1:-1] + "}"
    return glyph


def _decode_text_commands(rom, symbol, charmap, substitutions):
    address = symbol.address
    pending = deque(substitutions)
    out = []
    for _ in range(4096):
        command = rom.byte(symbol.bank, address)
        address += 1
        if command == 0x50:
            if pending:
                raise ValueError(
                    f"{symbol.name}: unused dynamic text substitutions")
            return "".join(out)
        if command == 0:
            while True:
                value = rom.byte(symbol.bank, address)
                address += 1
                if value == 0x50:
                    break
                if value in (0x57, 0x58, 0x5F):
                    if pending:
                        raise ValueError(
                            f"{symbol.name}: unused dynamic text substitutions")
                    return "".join(out)
                out.append(_text_glyph(value, charmap))
            continue
        if command in (1, 2, 9):
            if not pending:
                raise ValueError(
                    f"{symbol.name}: missing substitution for command "
                    f"${command:02X}")
            expected, token = pending.popleft()
            if command != expected:
                raise ValueError(
                    f"{symbol.name}: expected command ${expected:02X}, "
                    f"found ${command:02X}")
            out.append(token)
            address += 2 if command == 1 else 3
            continue
        raise ValueError(
            f"{symbol.name}: unsupported text command ${command:02X}")
    raise ValueError(f"{symbol.name}: text command stream is too long")


def extract_text(rom, symbols, manifest, out_dir):
    metadata = manifest["text"]
    charmap = manifest["charmap"]
    dynamic = metadata["dynamic"]
    trainer_headers = {
        map_label: {
            int(index): header for index, header in headers.items()
        }
        for map_label, headers in metadata["trainerHeaders"].items()
    }
    texts = {}
    for label in metadata["labels"]:
        texts[label] = _decode_text_commands(
            rom, _symbol(symbols, label), charmap, dynamic.get(label, []))

    util.write_lua(
        os.path.join(out_dir, "text.lua"), texts,
        header="Source: canonical Pokemon Red ROM text command streams")
    util.write_lua(
        os.path.join(out_dir, "text_pointers.lua"), metadata["pointers"],
        header="Map text integration metadata; dialogue is decoded from ROM")
    util.write_lua(
        os.path.join(out_dir, "trainer_headers.lua"),
        trainer_headers,
        header="Trainer integration metadata; dialogue is decoded from ROM")
    return {
        "texts": texts,
        "pointers": metadata["pointers"],
        "trainerHeaders": trainer_headers,
    }


def extract_field(rom, symbols, manifest, out_dir, assets_dir):
    def raw_2bpp(
            label, width, height, relative, transparent=False, matte=False,
            columns=False, stored_length=None):
        expected = width * height // 4
        length = expected if stored_length is None else stored_length
        symbol = _symbol(symbols, label)
        raw = rom.bytes(symbol.bank, symbol.address, length)
        if len(raw) < expected:
            raw += bytes(expected - len(raw))
        if columns:
            raw = _columns_to_rows(raw, width // 8, height // 8)
        image = _decode_2bpp(raw, width, height, transparent)
        if matte:
            image = _matte_color0(image)
        _save_png(image, os.path.join(assets_dir, relative))
        return image

    def raw_1bpp(label, width, height, relative, transparent=False):
        symbol = _symbol(symbols, label)
        raw = rom.bytes(symbol.bank, symbol.address, width * height // 8)
        image = _decode_1bpp(raw, width, height, transparent)
        _save_png(image, os.path.join(assets_dir, relative))
        return image

    raw_2bpp(
        "PokemonLogoGraphics", 128, 56, "title/pokemon_logo.png")
    raw_1bpp("Version_GFX", 80, 8, "title/red_version.png")
    raw_2bpp(
        "PlayerCharacterTitleGraphics", 40, 56, "title/player.png",
        matte=True)
    raw_2bpp(
        "NintendoCopyrightLogoGraphics", 152, 8,
        "title/copyright.png")
    raw_2bpp(
        "GameFreakLogoGraphics", 72, 8, "title/gamefreak_inc.png")
    # Yellow NineTile (pokeyellow gfx/font.asm): final "9" of (c)1995-1999,
    # parked in the 16 bytes between GameFreakLogoGraphics and TextBoxGraphics.
    if _has_symbol(symbols, "GameFreakLogoGraphics") \
       and _has_symbol(symbols, "TextBoxGraphics"):
        gf = _symbol(symbols, "GameFreakLogoGraphics")
        tb = _symbol(symbols, "TextBoxGraphics")
        if tb.address == gf.address + 9 * 16 + 16:
            nine_raw = rom.bytes(gf.bank, gf.address + 9 * 16, 16)
            _save_png(
                _decode_2bpp(nine_raw, 8, 8, False),
                os.path.join(assets_dir, "title/nine.png"))

    # Yellow fixed Pikachu title (pret/pokeyellow title_yellow.asm): tilemap
    # composition over both tile banks -- PokemonLogoGraphics in vChars2
    # (BG ids $00-$7F), TitlePikachuBGGraphics in vChars1 (ids $80-$EF),
    # TitlePikachuOBGraphics at vChars1 tile $70 (ids $F0-$FC, also the eye
    # OAM tiles), PokemonLogoCornerGraphics at vChars1 tile $7D ($FD-$FF).
    # Mirrors RomExtractor:extractYellowTitleArt.
    if _has_symbol(symbols, "TitlePikachuBGGraphics"):
        raw_2bpp(
            "TitlePikachuBGGraphics", 128, 32, "title/pikachu_bg.png",
            transparent=True)
        raw_2bpp(
            "TitlePikachuOBGraphics", 96, 8, "title/pikachu_ob.png",
            transparent=True)

        def sheet_tiles(label, count, transparent=False):
            symbol = _symbol(symbols, label)
            raw = rom.bytes(symbol.bank, symbol.address, count * 16)
            return [
                _decode_2bpp(raw[index:index + 16], 8, 8, transparent)
                for index in range(0, len(raw), 16)
            ]

        # tile counts = Graphics..GraphicsEnd symbol gaps in pokeyellow.sym
        logo_tiles = sheet_tiles("PokemonLogoGraphics", 115)
        corner_tiles = sheet_tiles("PokemonLogoCornerGraphics", 3)
        bg_tiles = sheet_tiles("TitlePikachuBGGraphics", 64)
        ob_tiles = sheet_tiles("TitlePikachuOBGraphics", 12)
        ob_clear = sheet_tiles("TitlePikachuOBGraphics", 12, True)

        def tile_for(tid):
            if tid < 0x80:
                return logo_tiles[tid]
            if tid < 0xF0:
                return bg_tiles[tid - 0x80]
            if tid < 0xFD:
                return ob_tiles[tid - 0xF0]
            return corner_tiles[tid - 0xFD]

        def matte_color0(pose):
            from collections import deque
            w, h = pose.size
            seen = set()
            q = deque()

            def add(x, y):
                if (x, y) in seen or not (0 <= x < w and 0 <= y < h):
                    return
                if pose.getpixel((x, y)) == (255, 255, 255, 255):
                    seen.add((x, y))
                    q.append((x, y))
            for x in range(w):
                add(x, 0); add(x, h - 1)
            for y in range(h):
                add(0, y); add(w - 1, y)
            while q:
                x, y = q.popleft()
                pose.putpixel((x, y), (255, 255, 255, 0))
                add(x - 1, y); add(x + 1, y); add(x, y - 1); add(x, y + 1)
            return pose

        def compose(cols, rows, cells):
            pose = Image.new("RGBA", (cols * 8, rows * 8), (255, 255, 255, 0))
            for tid, cx, cy in cells:
                pose.paste(tile_for(tid), (cx * 8, cy * 8))
            return pose

        def map_cells(label, cols, rows):
            loc = _symbol(symbols, label)
            ids = list(rom.bytes(loc.bank, loc.address, cols * rows))
            return [
                (tid, index % cols, index // cols)
                for index, tid in enumerate(ids)
            ]

        # 16x7 logo box at (2,1); overwrites the scrambled sequential rip
        # above (Yellow's logo sheet is deduplicated, Red's is not)
        _save_png(
            compose(16, 7, map_cells("TitleScreenPokemonLogoTilemap", 16, 7)),
            os.path.join(assets_dir, "title/pokemon_logo.png"))

        # 7x4 bubble at (6,4) + the two tail tiles poked at (9,8)
        bubble_cells = map_cells("TitleScreenPikaBubbleTilemap", 7, 4)
        bubble_cells += [(0x64, 3, 4), (0x65, 4, 4)]
        _save_png(
            matte_color0(compose(7, 5, bubble_cells)),
            os.path.join(assets_dir, "title/pika_bubble.png"))

        # 12x9 Pikachu at (4,8) + right-ear edge tiles down column 16 and
        # the baked OAM eyes (TitleScreenPikachuEyesOAMData, left eye
        # x-flipped, attr $22)
        pika_cells = map_cells("TitleScreenPikachuTilemap", 12, 9)
        pika_cells += [(0x96, 12, 2), (0x9d, 12, 3),
                       (0xa7, 12, 4), (0xb1, 12, 5)]
        pikachu = matte_color0(compose(13, 9, pika_cells))
        for ob_index, px, py, flip in (
                (1, 24, 16, True), (0, 32, 16, True),
                (3, 24, 24, True), (2, 32, 24, True),
                (0, 56, 16, False), (1, 64, 16, False),
                (2, 56, 24, False), (3, 64, 24, False)):
            eye = ob_clear[ob_index].copy()
            _apply_title_obp0(eye)
            if flip:
                eye = eye.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
            pikachu.paste(eye, (px, py), eye)
        _save_png(pikachu, os.path.join(assets_dir, "title/pikachu.png"))

        # Blink overlays (half/closed) — same OBP remap as open eyes.
        eye_layout = (
            (1, 24, 16, True), (0, 32, 16, True),
            (3, 24, 24, True), (2, 32, 24, True),
            (0, 56, 16, False), (1, 64, 16, False),
            (2, 56, 24, False), (3, 64, 24, False),
        )
        # Re-compose blank-face pika for overlays (open eyes already baked).
        blank_face = matte_color0(compose(13, 9, pika_cells))
        for suffix, base in (("eyes_half", 4), ("eyes_closed", 8)):
            overlay = Image.new("RGBA", (48, 16), (255, 255, 255, 0))
            overlay.paste(blank_face.crop((24, 16, 72, 32)), (0, 0))
            for ob_index, px, py, flip in eye_layout:
                eye = ob_clear[base + ob_index].copy()
                _apply_title_obp0(eye)
                if flip:
                    eye = eye.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
                overlay.paste(eye, (px - 24, py - 16), eye)
            _save_png(overlay, os.path.join(assets_dir, f"title/{suffix}.png"))

    falling_star = raw_2bpp(
        "FallingStar", 8, 8, "intro/falling_star.png",
        transparent=True)
    blink = Image.new("RGBA", falling_star.size, (255, 255, 255, 0))
    for y in range(falling_star.height):
        for x in range(falling_star.width):
            pixel = falling_star.getpixel((x, y))
            if pixel[3] and pixel[0] == 170:
                blink.putpixel((x, y), pixel)
    _save_png(blink, os.path.join(
        assets_dir, "intro/falling_star_blink.png"))

    gamefreak = _symbol(symbols, "GameFreakIntro")
    presents_raw = rom.bytes(
        gamefreak.bank, gamefreak.address, 104 * 8 // 4)
    presents = _decode_2bpp(
        presents_raw, 104, 8, transparent_color0=True)
    _save_png(
        presents, os.path.join(
            assets_dir, "intro/gamefreak_presents.png"))
    logo_raw = rom.bytes(
        gamefreak.bank, gamefreak.address + len(presents_raw),
        16 * 24 // 4)
    _save_png(
        _decode_2bpp(logo_raw, 16, 24, transparent_color0=True),
        os.path.join(assets_dir, "intro/gamefreak_logo.png"))

    text_image = Image.new("RGBA", (80, 8), (255, 255, 255, 0))
    for index, tile in enumerate((0, 1, 2, 3, None, 4, 5, 3, 1, 6)):
        if tile is not None:
            text_image.paste(
                presents.crop((tile * 8, 0, tile * 8 + 8, 8)),
                (index * 8, 0))
    _save_png(
        text_image,
        os.path.join(assets_dir, "intro/gamefreak_text.png"))

    move_tiles = _symbol(symbols, "MoveAnimationTiles1")
    star = Image.new("RGBA", (16, 16), (255, 255, 255, 0))
    for row, tile in ((0, 3), (1, 19)):
        tile_raw = rom.bytes(
            move_tiles.bank, move_tiles.address + tile * 16, 16)
        image = _decode_2bpp(
            tile_raw, 8, 8, transparent_color0=True)
        star.paste(image, (0, row * 8))
        star.paste(
            image.transpose(Image.Transpose.FLIP_LEFT_RIGHT),
            (8, row * 8))
    _save_png(star, os.path.join(assets_dir, "intro/big_star.png"))

    # Yellow replaces the Gengar/Nidorino fight intro (no FightIntro* symbols).
    # Still emit the Red/Blue asset paths so Title/Intro loaders stay happy.
    if _has_symbol(symbols, "FightIntroBackMon"):
        gengar = _symbol(symbols, "FightIntroBackMon")
        gengar_raw = rom.bytes(gengar.bank, gengar.address, 96 * 16)
        gengar_tiles = [
            _decode_2bpp(gengar_raw[index:index + 16], 8, 8)
            for index in range(0, len(gengar_raw), 16)
        ]
        for number in (1, 2, 3):
            tilemap = _symbol(symbols, f"GengarIntroTiles{number}")
            tile_ids = rom.bytes(tilemap.bank, tilemap.address, 49)
            pose = Image.new("RGBA", (56, 56))
            for index, tile_id in enumerate(tile_ids):
                pose.paste(
                    gengar_tiles[tile_id],
                    ((index % 7) * 8, (index // 7) * 8))
            pose = _matte_color0(pose)
            _save_png(
                pose, os.path.join(
                    assets_dir, "intro", f"gengar_{number}.png"))
    else:
        blank = Image.new("RGBA", (56, 56), (0, 0, 0, 0))
        for number in (1, 2, 3):
            _save_png(
                blank, os.path.join(
                    assets_dir, "intro", f"gengar_{number}.png"))

    if _has_symbol(symbols, "FightIntroFrontMon"):
        for number, label in enumerate((
                "FightIntroFrontMon", "FightIntroFrontMon2",
                "FightIntroFrontMon3"), start=1):
            raw_2bpp(
                label, 48, 48, f"intro/red_nidorino_{number}.png",
                transparent=True, columns=True)
    else:
        blank = Image.new("RGBA", (48, 48), (255, 255, 255, 0))
        for number in (1, 2, 3):
            _save_png(
                blank, os.path.join(
                    assets_dir, "intro", f"red_nidorino_{number}.png"))

    for number in (1, 2):
        _write_compressed_pic(
            rom, symbols, f"ShrinkPic{number}",
            os.path.join(assets_dir, "intro", f"shrink{number}.png"))

    raw_2bpp(
        "SlotMachineTiles1", 128, 24, "slots/red_slots_1.png",
        stored_length=0x250)
    slot_sheet = raw_2bpp(
        "SlotMachineTiles2", 32, 48, "slots/red_slots_2.png")
    transparent_slots = slot_sheet.copy()
    for y in range(transparent_slots.height):
        for x in range(transparent_slots.width):
            if transparent_slots.getpixel((x, y)) == (255, 255, 255, 255):
                transparent_slots.putpixel((x, y), (255, 255, 255, 0))
    slot_order = manifest["field"]["slotSymbols"]["order"]
    symbol_sheet = Image.new(
        "RGBA", (16 * len(slot_order), 16), (255, 255, 255, 0))
    for index, name in enumerate(slot_order):
        value = manifest["field"]["slotSymbols"]["symbols"][name]["tiles"]
        high, low = value >> 8, value & 0xFF
        for row, tile in ((0, high), (1, low)):
            x = (tile % 4) * 8
            y = (tile // 4) * 8
            symbol_sheet.paste(
                transparent_slots.crop((x, y, x + 16, y + 8)),
                (index * 16, row * 8))
    _save_png(
        symbol_sheet, os.path.join(assets_dir, "slots/symbols.png"))

    emotes = Image.new("RGBA", (48, 16), (255, 255, 255, 0))
    for index, label in enumerate(
            ("ShockEmote", "QuestionEmote", "HappyEmote")):
        symbol = _symbol(symbols, label)
        image = _decode_2bpp(
            rom.bytes(symbol.bank, symbol.address, 64), 16, 16,
            transparent_color0=True)
        emotes.paste(image, (index * 16, 0))
    _save_png(emotes, os.path.join(assets_dir, "emotes.png"))

    raw_1bpp(
        "LedgeHoppingShadow", 8, 8, "fx/shadow.png",
        transparent=True)
    for label, width, height, filename in (
            ("RedFishingRodTiles", 8, 24, "fishing_rod.png"),
            ("RedFishingTilesSide", 16, 8, "red_fish_side.png"),
            ("RedFishingTilesFront", 16, 8, "red_fish_front.png"),
            ("RedFishingTilesBack", 16, 8, "red_fish_back.png"),
            ("PokeCenterFlashingMonitorAndHealBall", 8, 16,
             "heal_machine.png"),
            ("SSAnneSmokePuffTile", 8, 8, "smoke.png")):
        raw_2bpp(
            label, width, height, "fx/" + filename,
            transparent=True)
    raw_2bpp(
        "BattleTransitionTile", 8, 8, "fx/battle_transition.png")
    raw_2bpp(
        "PokedexTileGraphics", 24, 48, "fx/pokedex.png")

    raw_2bpp(
        "HpBarAndStatusGraphics", 120, 16,
        "battle/font_battle_extra.png", transparent=True)
    for number, label in enumerate(
            ("BattleHudTiles1", "BattleHudTiles2", "BattleHudTiles3"),
            start=1):
        raw_1bpp(
            label, 24, 8, f"battle/battle_hud_{number}.png",
            transparent=True)

    the_end = _symbol(symbols, "TheEndGfx")
    interleaved = rom.bytes(the_end.bank, the_end.address, 160)
    reordered = bytearray(160)
    for column in range(5):
        reordered[column * 16:(column + 1) * 16] = \
            interleaved[column * 32:column * 32 + 16]
        reordered[(column + 5) * 16:(column + 6) * 16] = \
            interleaved[column * 32 + 16:column * 32 + 32]
    _save_png(
        _decode_2bpp(bytes(reordered), 40, 16),
        os.path.join(assets_dir, "credits/the_end.png"))

    if _has_symbol(symbols, "SurfingPikachu1Graphics1"):
        raw_2bpp("SurfingPikachu1Graphics1", 40, 104, "minigame/surf_1a.png", transparent=False)
        raw_2bpp("SurfingPikachu1Graphics2", 128, 128, "minigame/surf_1b.png", transparent=True)
        raw_2bpp("SurfingPikachu1Graphics3", 96, 96, "minigame/surf_1c.png", transparent=True)

        beach_sym = _symbol(symbols, "SurfingMinigame_BeachIntroTilemap")
        use_ctrl_sym = _symbol(symbols, "SurfingMinigame_UseControlPadTilemap")
        to_surf_sym = _symbol(symbols, "SurfingMinigame_ToSurfRadTilemap")
        title_sym = _symbol(symbols, "SurfingMinigame_TitleTilemap")
        beach_intro = rom.bytes(beach_sym.bank, beach_sym.address, 240)
        use_ctrl_pad = rom.bytes(use_ctrl_sym.bank, use_ctrl_sym.address, 15)
        to_surf_rad = rom.bytes(to_surf_sym.bank, to_surf_sym.address, 13)
        title_map = rom.bytes(title_sym.bank, title_sym.address, 72)
        screen = [0xff] * (20 * 18)
        for i in range(240):
            screen[6 * 20 + i] = beach_intro[i]
        for r in range(6):
            for c in range(12):
                screen[r * 20 + (4 + c)] = title_map[r * 12 + c]
        for r in range(3):
            for c in range(15):
                screen[(7 + r) * 20 + (3 + c)] = 0xff
        for i in range(15):
            screen[7 * 20 + 3 + i] = use_ctrl_pad[i]
        for i in range(13):
            screen[9 * 20 + 4 + i] = to_surf_rad[i]

        sym3 = _symbol(symbols, "SurfingPikachu1Graphics3")
        raw_gfx3 = rom.bytes(sym3.bank, sym3.address, 144 * 16)
        tiles = [_decode_2bpp(raw_gfx3[i*16:(i+1)*16], 8, 8) for i in range(144)]
        blank = Image.new("RGBA", (8, 8), (255, 255, 255, 255))
        title_bg = Image.new("RGBA", (160, 144), (255, 255, 255, 255))
        for r in range(18):
            for c in range(20):
                t_id = screen[r * 20 + c]
                if t_id == 0xff:
                    tile_img = blank
                elif t_id >= 0x80:
                    idx = t_id - 0x80
                    tile_img = tiles[idx] if idx < 144 else blank
                else:
                    idx = 128 + t_id
                    tile_img = tiles[idx] if idx < 144 else blank
                title_bg.paste(tile_img, (c * 8, r * 8))
        _save_png(title_bg, os.path.join(assets_dir, "minigame/title_bg.png"))
    raw_2bpp("WorldMapTileGraphics", 32, 32, "townmap/tiles.png")
    raw_1bpp(
        "TownMapCursor", 16, 16, "townmap/cursor.png",
        transparent=True)

    data = copy.deepcopy(manifest["field"])
    adjacency = data["hiddenExtras"]["trashCans"]["adjacent"]
    data["hiddenExtras"]["trashCans"]["adjacent"] = {
        int(index): values for index, values in adjacency.items()
    }
    data["source"] = "canonical Pokemon Red ROM + bundled port metadata"
    util.write_lua(
        os.path.join(out_dir, "field.lua"), data,
        header="Field integration metadata; all referenced artwork is "
               "decoded from ROM")
    return data


def build(rom, symbols, manifest, out_dir, assets_dir, datasets):
    results = {}
    if "constants" in datasets:
        results["constants"] = extract_constants(manifest, out_dir)
    if "tilesets" in datasets:
        results["tilesets"] = extract_tilesets(
            rom, symbols, manifest, out_dir, assets_dir)
    if "maps" in datasets:
        results["maps"] = extract_maps(
            rom, symbols, manifest, out_dir)
    if "font" in datasets:
        results["font"] = extract_font(
            rom, symbols, manifest, out_dir, assets_dir)
    if "sprites" in datasets:
        results["sprites"] = extract_sprites(
            rom, symbols, manifest, out_dir, assets_dir)
    if "moves" in datasets:
        results["moves"] = extract_moves(
            rom, symbols, manifest, out_dir)
    if "battle_anims" in datasets:
        results["battle_anims"] = extract_battle_anims(
            rom, symbols, manifest, out_dir, assets_dir)
    if "items" in datasets:
        results["items"] = extract_items(
            rom, symbols, manifest, out_dir)
    if "type_chart" in datasets:
        results["type_chart"] = extract_type_chart(
            rom, symbols, manifest, out_dir)
    if "palettes" in datasets:
        results["palettes"] = extract_palettes(
            rom, symbols, manifest, out_dir)
    if "icons" in datasets:
        results["icons"] = extract_icons(
            rom, symbols, manifest, out_dir, assets_dir)
    if "pokemon" in datasets:
        results["pokemon"] = extract_pokemon(
            rom, symbols, manifest, out_dir, assets_dir)
    if "trainers" in datasets:
        results["trainers"] = extract_trainers(
            rom, symbols, manifest, out_dir, assets_dir)
    if "encounters" in datasets:
        results["encounters"] = extract_encounters(
            rom, symbols, manifest, out_dir)
    if "text" in datasets:
        results["text"] = extract_text(
            rom, symbols, manifest, out_dir)
    if "field" in datasets:
        results["field"] = extract_field(
            rom, symbols, manifest, out_dir, assets_dir)
    return results


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--rom", required=True,
        help="canonical US Pokemon Red, Blue, or Yellow ROM")
    parser.add_argument(
        "--version", choices=["auto", *sorted(VERSION_MANIFESTS)], default="auto",
        help="select the shipped manifest for this version (default: detect from ROM)")
    parser.add_argument(
        "--manifest", default=None,
        help="explicit manifest path (overrides --version default path; "
             "RomImage hash still comes from the file's romSha1)")
    parser.add_argument(
        "--out", default=None,
        help="generated data directory (default: version-specific cache path)")
    parser.add_argument(
        "--assets", default=None,
        help="generated assets directory (default: version-specific cache path)")
    parser.add_argument("--clean", action="store_true")
    parser.add_argument(
        "--only", action="append", choices=DATASETS,
        help="build one dataset (repeatable); default builds all implemented")
    args = parser.parse_args(argv)

    try:
        manifest_explicit = args.manifest is not None
        requested_version = None if args.version == "auto" else args.version
        if manifest_explicit:
            manifest_path = resolve_manifest_path(
                requested_version or "red", args.manifest)
            manifest = load_manifest(manifest_path)
            version = version_for_manifest(
                manifest, requested_version, manifest_explicit=True)
            expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version]
            rom = RomImage(args.rom, expected_sha1)
        elif requested_version is None:
            version, rom = detect_rom_version(args.rom)
            manifest_path = resolve_manifest_path(version, None)
            manifest = load_manifest(manifest_path)
            expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version]
            if rom.sha1 != expected_sha1:
                raise ValueError(
                    f"unsupported ROM SHA-1 {rom.sha1}; expected {expected_sha1}")
        else:
            version = requested_version
            manifest_path = resolve_manifest_path(version, None)
            manifest = load_manifest(manifest_path)
            version = version_for_manifest(manifest, version)
            expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version]
            rom = RomImage(args.rom, expected_sha1)
        symbols = SymbolTable(manifest["symbols"])
    except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1

    prefix = "" if version == "red" else version + os.sep
    out_dir = args.out or prefix + os.path.join("data", "generated")
    assets_dir = args.assets or prefix + os.path.join("assets", "generated")
    if args.clean:
        kept = clean_generated(out_dir, assets_dir)
        for path in kept:
            print(f"kept {path} (not produced by this tool)")
    os.makedirs(out_dir, exist_ok=True)
    os.makedirs(assets_dir, exist_ok=True)
    datasets = tuple(args.only) if args.only else DATASETS
    try:
        build(rom, symbols, manifest, out_dir, assets_dir, datasets)
    except (ValueError, KeyError, IndexError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1
    print(
        f"\ndone: decoded {', '.join(datasets)} from {version} ROM {rom.sha1}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
