#!/usr/bin/env python3
"""Generate tools/rom_manifest_gold.json from pret/pokegold + pokegold.sym.

Phase 1 Gen 2 (Pokemon Gold) counterpart to tools/make_rom_manifest.py.  It
deliberately does not reuse tools/extract/* (that package is pokered-shaped:
ASM_DEFINES pins _RED, and several helpers assume Gen 1's flat map order and
Kanto dex numbering), so this file carries its own small RGBDS constant
parser instead.

Differences from Gen 1 that shape this file:
  - Species order IS dex order: BaseData's rows are declared in
    constants/pokemon_constants.asm order, and each row's own first byte is
    that same dex number (data/pokemon/base_stats/*.asm `db <SPECIES>`).
    There is no separate dexOrder/gen1_order remap to carry.
  - Maps are grouped (MAPGROUP_*/MAP_*, constants/map_constants.asm's
    `newgroup`/`map_const`/`endgroup`), not one flat table.
  - Pokemon pics and tileset graphics are lz3-compressed
    (home/decompress.asm), not pkmncompress'd -- see Rom.decompressLz3.

Usage: python3 tools/make_gold_manifest.py
Default paths: pokegold at ../pokegold (relative to the repo) or
/Users/bryanbassett/Documents/development/pokegold; symbols at
/Users/bryanbassett/Documents/development/pokegold-symbols/pokegold.sym.
"""

from __future__ import annotations

import argparse
import json
import os
import sys

# Reuse Gen 1's fontCharmap parser (seq→code for Font.encode); Gold's
# constants/charmap.asm is the same shape for the $60-$FF draw range.
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
from extract import font as font_extract  # noqa: E402
import re
import sys

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

from rom_data import CANONICAL_GOLD_SHA1, SymbolTable  # noqa: E402

REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_POKEGOLD_CANDIDATES = [
    os.path.join(os.path.dirname(REPO_ROOT), "pokegold"),
    "/Users/bryanbassett/Documents/development/pokegold",
]
DEFAULT_SYMBOLS = (
    "/Users/bryanbassett/Documents/development/pokegold-symbols/pokegold.sym")

# pokegold builds Gold with _GOLD defined (rgbdscheck.asm / the Makefile's
# `gold` target); none of the constants files this script reads are
# actually gated on it today, but resolving IF DEF(_GOLD)/_SILVER here
# (rather than skipping IF entirely, like tools/extract/util.py does for
# unmodeled conditions) keeps this future-proof if that ever changes.
ASM_DEFINES = {"_GOLD"}


def strip_comment(line):
    out = []
    in_str = False
    for ch in line:
        if ch == '"':
            in_str = not in_str
        elif ch == ";" and not in_str:
            break
        out.append(ch)
    return "".join(out).rstrip()


def read_asm(path, defines=None):
    """Read an asm file as (lineno, text), comments stripped, IF resolved.

    `defines` overrides ASM_DEFINES for another tree; pokecrystal's retail
    v1.0 target passes no -D at all (../pokecrystal/Makefile:129).
    """
    if defines is None:
        defines = ASM_DEFINES
    lines = []
    stack = []  # stack of [taking, condition_known]
    with open(path, encoding="utf-8") as f:
        for lineno, raw in enumerate(f, 1):
            line = strip_comment(raw.rstrip("\n"))
            s = line.strip()
            m = re.match(r"IF\s+(!)?DEF\((\w+)\)\s*$", s, re.IGNORECASE)
            if m:
                defined = m.group(2) in defines
                taking = (not defined) if m.group(1) else defined
                stack.append([taking, True])
                continue
            if re.match(r"IF\b", s):
                stack.append([True, False])  # unmodeled condition: keep body
                continue
            if re.match(r"ELSE\s*$", s, re.IGNORECASE) and stack:
                if stack[-1][1]:
                    stack[-1][0] = not stack[-1][0]
                continue
            if re.match(r"ENDC\s*$", s, re.IGNORECASE) and stack:
                stack.pop()
                continue
            if any(not fr[0] for fr in stack):
                continue
            lines.append((lineno, line))
    return lines


def parse_number(tok):
    tok = tok.strip()
    neg = tok.startswith("-")
    if neg:
        tok = tok[1:].strip()
    if tok.startswith("$"):
        val = int(tok[1:], 16)
    elif tok.startswith("%"):
        val = int(tok[1:], 2)
    elif tok.isdigit():
        val = int(tok)
    else:
        raise ValueError(f"not a number: {tok!r}")
    return -val if neg else val


def parse_const_block(path, stop_at=None, defines=None):
    """Parse a linear const_def/const/const_skip block into an ordered list.

    Index i of the returned list is the constant's value (None for a gap);
    the same shape tools/extract/util.parse_const_block returns for Gen 1.
    """
    names = []
    value = None
    for _, line in read_asm(path, defines):
        s = line.strip()
        if not s:
            continue
        if stop_at and re.match(rf"DEF\s+{stop_at}\b", s):
            break
        m = re.match(r"const_def(?:\s+(\$?\w+))?$", s)
        if m:
            value = parse_number(m.group(1)) if m.group(1) else 0
            continue
        m = re.match(r"const_next\s+(\$?\w+)$", s)
        if m:
            value = parse_number(m.group(1))
            continue
        m = re.match(r"const\s+(\w+)", s)
        if m and value is not None:
            while len(names) < value:
                names.append(None)
            names.append(m.group(1))
            value += 1
            continue
        m = re.match(r"const_skip(?:\s+(\d+))?$", s)
        if m and value is not None:
            n = int(m.group(1)) if m.group(1) else 1
            for _ in range(n):
                names.append(None)
            value += n
    return names


def parse_const_block_at(path, first_const, stop_at=None, defines=None):
    """Parse the one const_def block whose first `const` is `first_const`.

    parse_const_block walks a file linearly and a second `const_def` in the
    same file rewinds its counter without rewinding the list, so it can only
    read a file's FIRST block.  phone_constants.asm stacks two (PHONE_* then
    SPECIALCALL_*) and script_constants.asm stacks a dozen, so anything past
    the first needs to be found by name.  Holes are kept as None: PHONE_* has
    four `const_skip`s in the middle and they are real PhoneContacts rows.
    """
    names = []
    value = None
    started = False
    for _, line in read_asm(path, defines):
        s = line.strip()
        if not s:
            continue
        m = re.match(r"const_def(?:\s+(\$?\w+))?$", s)
        if m:
            if started:
                break
            value = parse_number(m.group(1)) if m.group(1) else 0
            names = []
            continue
        if started and stop_at and re.match(rf"DEF\s+{stop_at}\b", s):
            break
        m = re.match(r"const\s+(\w+)", s)
        if m and value is not None:
            if not started:
                if m.group(1) != first_const:
                    continue
                started = True
            while len(names) < value:
                names.append(None)
            names.append(m.group(1))
            value += 1
            continue
        m = re.match(r"const_skip(?:\s+(\d+))?$", s)
        if m and started and value is not None:
            n = int(m.group(1)) if m.group(1) else 1
            for _ in range(n):
                names.append(None)
            value += n
    if not started:
        raise SystemExit(f"{path}: no const block starting at {first_const}")
    return names


def parse_prefixed_consts(path, prefixes, exact=(), defines=None):
    """Ordered const names from a file, filtered by prefix (mixed blocks).

    map_data_constants.asm and friends stack several unrelated const_def
    blocks in one file, so scraping by prefix is the only way to pull one
    of them out without the neighbours bleeding in.  `exact` names are kept
    even when they carry no shared prefix (TOWN, BALL, ...).
    """
    out = []
    for _, line in read_asm(path, defines):
        m = re.match(r"const\s+(\w+)", line.strip())
        if not m:
            continue
        name = m.group(1)
        if name in exact or any(name.startswith(p) for p in prefixes):
            out.append(name)
    return out


def parse_sparse_consts(path, prefixes, defines=None):
    """Ordered const names where index IS the const value (sparse blocks).

    parse_prefixed_consts packs a block densely, which is wrong for a block
    that jumps: constants/item_data_constants.asm's HELD_* run uses
    const_skip holes and const_next jumps to 10/20/30/40/50/70, so a dense
    scrape shifts every value after the first hole (HELD_HEAL_POISON is 10,
    not 7 -- and QUICK_CLAW at 74 fell off the end of the list entirely).
    Holes carry placeholder names because the manifest's JSON round trip
    collapses nulls out of an array.
    """
    out = []
    value = None
    for _, line in read_asm(path, defines):
        s = line.strip()
        m = re.match(r"const_def(?:\s+(\d+))?\s*$", s)
        if m:
            value = int(m.group(1) or 0)
            continue
        m = re.match(r"const_next\s+(\d+)\s*$", s)
        if m and value is not None:
            value = int(m.group(1))
            continue
        m = re.match(r"const_skip(?:\s+(\d+))?\s*$", s)
        if m and value is not None:
            value += int(m.group(1) or 1)
            continue
        m = re.match(r"const\s+(\w+)", s)
        if not m or value is None:
            continue
        name = m.group(1)
        if any(name.startswith(p) for p in prefixes):
            while len(out) < value:
                out.append("%sUNUSED_%d" % (prefixes[0], len(out)))
            out.append(name)
        value += 1
    return out


def extract_trainer_classes(pokegold, defines=None):
    """Ordered trainer class names (constants/trainer_constants.asm).

    `trainerclass NAME` bumps its own counter and resets the const_def used
    for that class's individual trainer ids, so parse_const_block cannot see
    them -- the two interleaved sequences need separate passes.  Index in the
    returned list IS the class id (TRAINER_NONE = 0).
    """
    path = os.path.join(pokegold, "constants/trainer_constants.asm")
    classes = []
    members = {}
    current = None
    for _, line in read_asm(path, defines):
        s = line.strip()
        m = re.match(r"trainerclass\s+(\w+)", s)
        if m:
            current = m.group(1)
            classes.append(current)
            members[current] = []
            continue
        m = re.match(r"const\s+(\w+)", s)
        if m and current and not m.group(1).startswith("PHONECONTACT_"):
            members[current].append(m.group(1))
    if not classes or classes[0] != "TRAINER_NONE" or classes[1] != "FALKNER":
        raise ValueError("trainer_constants.asm did not parse as expected")
    return classes, members


def extract_items(pokegold, defines=None):
    """Ordered item ids, and how many of them ItemNames actually covers.

    parse_const_block cannot do this file: after NUM_ITEMS the TM and HM items
    are declared through `add_tm DYNAMICPUNCH` / `add_hm CUT` macros that expand
    to `const TM_DYNAMICPUNCH` / `const HM_CUT`, and two plain `const ITEM_C3` /
    `ITEM_DC` sit *inside* that run consuming ids without being TMs.  Stopping
    at NUM_ITEMS (as a linear parse must) loses all 57 TM/HM items; ignoring it
    loses the boundary where ItemNames stops having rows.

    Returns (order, name_count) where order[i - 1] is item id i; NO_ITEM (0) is
    dropped so the list is 1-based on the id, matching ItemNames' rows.
    """
    path = os.path.join(pokegold, "constants/item_constants.asm")
    order = []
    name_count = None
    in_macro = False
    for _, line in read_asm(path, defines):
        s = line.strip()
        # The add_tm / add_hm macro bodies contain `const TM_\1` themselves;
        # counting those would insert a phantom "TM_" item before the real run.
        if re.match(r"MACRO\b", s):
            in_macro = True
            continue
        if re.match(r"ENDM\b", s):
            in_macro = False
            continue
        if in_macro:
            continue
        if re.match(r"DEF\s+NUM_ITEMS\b", s):
            # NUM_ITEMS is const_value - 1, i.e. however many ids came before.
            name_count = len(order)
            continue
        m = re.match(r"const\s+(\w+)", s)
        if m:
            order.append(m.group(1))
            continue
        m = re.match(r"add_tm\s+(\w+)", s)
        if m:
            order.append("TM_" + m.group(1))
            continue
        m = re.match(r"add_hm\s+(\w+)", s)
        if m:
            order.append("HM_" + m.group(1))
            continue
    if not order or order[0] != "NO_ITEM":
        raise ValueError("item_constants.asm did not parse as expected")
    if not name_count:
        raise ValueError("item_constants.asm: NUM_ITEMS not found")
    # Drop NO_ITEM and the count that included it.
    return order[1:], name_count - 1


def extract_specials(pokegold, defines=None):
    """Ordered SpecialsPointers labels (data/events/special_pointers.asm).

    The `special` script command carries an index into this table, not a name,
    so without the order a disassembled `special 27` says nothing.  Index in the
    returned list IS the id.
    """
    path = os.path.join(pokegold, "data/events/special_pointers.asm")
    names = []
    in_macro = False
    for _, line in read_asm(path, defines):
        s = line.strip()
        if re.match(r"MACRO\b", s):
            in_macro = True
            continue
        if re.match(r"ENDM\b", s):
            in_macro = False
            continue
        if in_macro:
            continue
        m = re.match(r"add_special\s+(\w+)", s)
        if m:
            names.append(m.group(1))
    if not names or names[0] != "WarpToSpawnPoint":
        raise ValueError("special_pointers.asm did not parse as expected")
    return names


def extract_std_scripts(pokegold, defines=None):
    """Ordered StdScripts labels (engine/events/std_scripts.asm)."""
    path = os.path.join(pokegold, "engine/events/std_scripts.asm")
    names = []
    for _, line in read_asm(path, defines):
        m = re.match(r"add_stdscript\s+(\w+)", line.strip())
        if m:
            names.append(m.group(1))
    if not names or names[0] != "PokecenterNurseScript":
        raise ValueError("std_scripts.asm did not parse as expected")
    return names


def extract_map_groups(pokegold, defines=None):
    """Parse constants/map_constants.asm's newgroup/map_const/endgroup."""
    path = os.path.join(pokegold, "constants/map_constants.asm")
    order = []
    groups = {}
    group = 0
    map_index = 0
    for _, line in read_asm(path, defines):
        s = line.strip()
        if re.match(r"newgroup\s+\w+", s):
            group += 1
            map_index = 0
            continue
        m = re.match(r"map_const\s+(\w+),\s*([\d$%-]+),\s*([\d$%-]+)", s)
        if m:
            map_index += 1
            name = m.group(1)
            order.append(name)
            groups[name] = {
                "name": name,
                "group": group,
                "map": map_index,
                "width": parse_number(m.group(2)),
                "height": parse_number(m.group(3)),
            }
    if not order or order[0] != "OLIVINE_POKECENTER_1F":
        raise ValueError("map_constants.asm did not parse as expected")
    return order, groups


def extract_types(pokegold, defines=None):
    """Type constants are physical IDs, a gap, then special IDs (Gen 1-style)."""
    path = os.path.join(pokegold, "constants/type_constants.asm")
    types = {}
    value = None
    for _, line in read_asm(path, defines):
        s = line.strip()
        m = re.match(r"const_def(?:\s+(\$?\w+))?$", s)
        if m:
            value = parse_number(m.group(1)) if m.group(1) else 0
            continue
        m = re.match(r"const_next\s+(\$?\w+)$", s)
        if m:
            value = parse_number(m.group(1))
            continue
        m = re.match(r"const\s+(\w+)", s)
        if m and value is not None:
            types[m.group(1)] = value
            value += 1
    if types.get("NORMAL") != 0 or "DARK" not in types:
        raise ValueError("type_constants.asm did not parse as expected")
    return types


def charmap(pokegold, defines=None):
    """Byte -> text charmap, same shape as make_rom_manifest.charmap."""
    expansions = {
        "<DOT>": ".",
        "<LV>": "{LV}",
        "<ID>": "{ID}",
        # ../pokecrystal/constants/charmap.asm:6 -- $14 is "<PLAYER>" in English
        "<PLAY_G>": "<PLAYER>",
        # Compression bytes: the cart stores one byte and PlaceString expands
        # it into several glyphs.  The font sheet only starts at $60, so these
        # three have no tile of their own and MUST be expanded here or the
        # decoded text asks the font for a glyph that cannot exist -- which is
        # what turns "#DEX" into "DEX" and "<POKE>GEAR" into "<POKE>GEAR".
        # The comments in charmap.asm name each expansion.
        "#": "POKé",           # $54
        "<POKE>": "<PO><KE>",  # $24
        "<PKMN>": "<PK><MN>",  # $4a
    }
    out = {}
    path = os.path.join(pokegold, "constants/charmap.asm")
    for _, line in read_asm(path, defines):
        stripped = line.strip()
        # ../pokecrystal/constants/charmap.asm:423,433 -- the unown and ascii
        # charmaps are separate RGBDS charmaps, not more rows of the main one.
        if re.match(r"(pushc|newcharmap)\b", stripped):
            break
        m = re.match(
            r'charmap\s+"((?:[^"\\]|\\.)*)",\s*(\$[0-9a-fA-F]+)',
            stripped)
        if not m:
            continue
        value = int(m.group(2)[1:], 16)
        if str(value) in out:
            continue
        seq = m.group(1).replace('\\"', '"')
        out[str(value)] = expansions.get(seq, seq)
    return out


def species_label(species_id):
    """SPECIES_CONST_NAME -> the SpeciesConstName label pics use.

    Matches every dba_pics entry in data/pokemon/pic_pointers.asm: strip
    underscores (NIDORAN_F -> NidoranF, MR__MIME -> MrMime,
    FARFETCH_D -> FarfetchD, HO_OH -> HoOh) and title-case what remains.
    """
    parts = [part for part in species_id.split("_") if part]
    return "".join(part.capitalize() for part in parts)


def pokemon_names(pokegold, defines=None):
    """PokemonNames dname entries, in declared (dex) order."""
    names = []
    path = os.path.join(pokegold, "data/pokemon/names.asm")
    for _, line in read_asm(path, defines):
        m = re.match(r'dname\s+"([^"]*)"', line.strip())
        if m:
            names.append(m.group(1))
    return names


def music_order(pokegold, defines=None):
    """Music_* labels in MUSIC_* id order from audio/music_pointers.asm."""
    path = os.path.join(pokegold, "audio/music_pointers.asm")
    order = []
    for _, line in read_asm(path, defines):
        m = re.match(r"dba\s+(Music_\w+)\s*$", line.strip())
        if m:
            order.append(m.group(1))
    if not order:
        raise ValueError("no Music_* dba rows in audio/music_pointers.asm")
    return order


def sfx_order(pokegold, defines=None):
    """Sfx_* labels in SFX_* id order from audio/sfx_pointers.asm."""
    path = os.path.join(pokegold, "audio/sfx_pointers.asm")
    order = []
    for _, line in read_asm(path, defines):
        m = re.match(r"dba\s+(Sfx_\w+)\s*$", line.strip())
        if m:
            order.append(m.group(1))
    if not order:
        raise ValueError("no Sfx_* dba rows in audio/sfx_pointers.asm")
    return order


# ../pokecrystal/main.asm:425-448 -- the four per-species pic-animation
# tables; field name in the manifest, symbol suffix in pokecrystal.sym.
ANIM_LABEL_SUFFIXES = (
    ("animLabel", "Animation"),
    ("idleLabel", "AnimationIdle"),
    ("bitmaskLabel", "Bitmasks"),
    ("framesLabel", "Frames"),
)


def _anim_labels(base, symbols):
    """{animLabel/idleLabel/bitmaskLabel/framesLabel: label or None}."""
    out = {}
    for field, suffix in ANIM_LABEL_SUFFIXES:
        label = base + suffix
        out[field] = label if label in symbols.by_name else None
    return out


def pokemon_assets(pokegold, species_order, symbols, defines=None,
                   anim_labels=False):
    """{species: {id, name, front, back, frontLabel, backLabel}}.

    Unown shares one pic per letter through UnownPicPointers (its
    dba_pics row in pic_pointers.asm is deliberately blank) rather than a
    per-species Frontpic/Backpic label, so its front/back/labels are left
    null; the extractor's own Unown handling (if any) goes through
    UnownPicPointers directly instead of this table.

    `anim_labels` adds the four pic-animation labels per species; off by
    default because pokegold.sym carries none of them.
    """
    names = pokemon_names(pokegold, defines)
    assets = {}
    for index, species in enumerate(species_order):
        name = names[index] if index < len(names) else species
        base = species_label(species)
        if species == "UNOWN":
            # No per-species pic, but it does have a dex entry like anything
            # else, so the #DEX screen can still read it.
            assets[species] = {
                "id": species, "name": name,
                "front": None, "back": None,
                "frontLabel": None, "backLabel": None,
                "dexLabel": "UnownPokedexEntry",
            }
            if anim_labels:
                assets[species].update(_anim_labels(base, symbols))
            continue
        front_label, back_label = base + "Frontpic", base + "Backpic"
        if front_label not in symbols.by_name or back_label not in symbols.by_name:
            raise ValueError(
                f"{species}: expected pic symbols {front_label}/{back_label} "
                "are missing from pokegold.sym")
        # Pokedex entry symbol, resolved here for the same reason the pic
        # labels are: the entries live in four different banks and the game
        # derives the bank from the species id arithmetically.
        dex_label = base + "PokedexEntry"
        assets[species] = {
            "id": species, "name": name,
            "front": base.lower(), "back": base.lower() + "_back",
            "frontLabel": front_label, "backLabel": back_label,
            "dexLabel": dex_label if dex_label in symbols.by_name else None,
        }
        if anim_labels:
            assets[species].update(_anim_labels(base, symbols))
    return assets


REQUIRED_SYMBOLS = {
    "BaseData", "PokemonNames", "PokemonPicPointers", "UnownPicPointers",
    "EggPic",
    # gfx/evo/egg_hatch.2bpp, the two OBJ tiles EggHatch_AnimationSequence
    # copies to vTiles0 tile $00 for the shell crack and the ten fragments
    # (engine/pokemon/breeding.asm:777).
    "EggHatchGFX",
    "Font", "FontExtra", "FontBattleExtra", "Frames",
    # The extra font page is NOT FontExtra laid down from $60: _LoadFontsExtra
    # (engine/gfx/load_font.asm:7-20) builds it from three sources, and the
    # first three tiles come from the other two.  gfx/font.asm keeps them well
    # away from FontExtra, so each needs its own symbol:
    #   $60-$61  FontsExtra_SolidBlackAndUpArrowGFX, 2 tiles, 1bpp
    #            (gfx/font/black.1bpp + gfx/font/up_arrow.1bpp)
    #   $62      PokegearPhoneIconGFX, 1 tile, 2bpp (gfx/font/phone_icon.2bpp)
    #   $63+     FontExtra + 3 tiles, 22 tiles, 2bpp
    # Loading FontExtra from $60 instead puts its unused <BOLD_A>/<BOLD_B>/
    # <BOLD_C> in those three cells -- constants/charmap.asm:41 marks that
    # $62 mapping "unused" and :88 gives $62 to "☎" -- which is why the
    # Pokegear caller box drew a bold C where the phone icon belongs.
    "PokegearPhoneIconGFX", "FontsExtra_SolidBlackAndUpArrowGFX",
    # gfx/font/unown_font.2bpp (gfx/font.asm UnownFont): the 26 Unown letters
    # plus the diamond cursor, loaded at vTiles2 tile FIRST_UNOWN_CHAR ($40)
    # by Pokedex_LoadUnownFont for the #DEX's UNOWN MODE.
    "UnownFont",
    "TitleScreenGFX1", "TitleScreenGFX2", "TitleScreenGFX3",
    "TitleScreenGFX4", "TitleScreenTilemap",
    "CopyrightGFX", "Tilesets", "MapGroupPointers", "Music", "Cries",
    # _AnimateTileset's `dw arg / dw function` programs (data/tileset_anims.asm)
    # and the two shared frame strips (engine/tilesets/tileset_anims.asm:194,
    # :225): the extractor resolves a tileset's Anim pointer against these.
    "DoneTileAnimation", "WaitTileAnimation",
    "StandingTileFrame", "StandingTileFrame8",
    "AnimateWaterTile", "AnimateFlowerTile", "AnimateWaterPalette",
    "ReadTileToAnimBuffer", "WriteTileFromAnimBuffer",
    "ScrollTileRightLeft", "ScrollTileDown", "ScrollTileUp",
    "ScrollTileLeft", "ScrollTileRight", "AnimateWhirlpoolTile",
    "AnimateLavaBubbleTile1", "AnimateLavaBubbleTile2",
    "AnimateTowerPillarTile", "FlickeringCaveEntrancePalette",
    "AnimateWaterTile.WaterTileFrames", "AnimateFlowerTile.FlowerTileFrames",
    # AnimateLavaBubbleTile1/2 hardcode their source (tileset_anims.asm:251,
    # :276); the whirlpool and tower-pillar strips come off the pointer pairs
    # their own tileframe arguments name, so they need no symbol here.
    "LavaBubbleTileFrames",
    # data/sprites/emotes.asm:22 `emote GrassRustleGFX, 1, $fe`, the one tile
    # ShakeGrass' SPRITEMOVEDATA_GRASS object draws.
    "GrassRustleGFX",
    "PokemonCries", "SFX",
    "OverworldSprites", "ChrisSpriteGFX", "Moves", "EvosAttacksPointers",
    # data/sprites/sprite_mons.asm: one species byte per SPRITE_POKEMON id,
    # which GetMonSprite's .Icon arm feeds to LoadOverworldMonIcon
    "SpriteMons",
    # Phase 2: roofs overlay outdoor Johto towns (engine/tilesets/mapgroup_roofs.asm)
    "MapGroupRoofs", "Roofs",
    # New-game object visibility (engine/events/std_scripts.asm InitializeEventsScript)
    "InitializeEventsScript",
    # Gen 2 audio driver: wave RAM patterns + drum kit pointer table
    "WaveSamples", "Drumkits",
    # Oak speech intro (engine/menus/intro_menu.asm OakSpeech)
    "PokemonProfPic", "CalPic",
    "_OakText1", "_OakText2", "_OakText3", "_OakText4",
    "_OakText5", "_OakText6", "_OakText7",
    # Boot cinema (splash.asm / ShrinkPlayer).  GameFreakLogoGFX is two
    # INCBINs run together -- gamefreak_presents.1bpp (13 tiles) then
    # gamefreak_logo.1bpp (15) -- and GameFreakLogoStarsGFX is another two,
    # logo_star.2bpp (2) then logo_sparkle.2bpp (3).
    "GameFreakLogoGFX", "GameFreakLogoStarsGFX", "Shrink1Pic", "Shrink2Pic",
    # Credits roll graphics (engine/movie/credits.asm).  CreditsBorderGFX is
    # the 9-tile strip on rows 4 and 13; the four mon labels are 4x4-tile
    # frames stacked (3 frames each, Sentret 4); TheEndGFX lives in its own
    # "The End" section in gfx/misc.asm; CreditsPalettes is gfx/credits/
    # credits.pal, six four-colour sets.
    "CreditsBorderGFX", "CreditsBellossomGFX", "CreditsTogepiGFX",
    "CreditsElekidGFX", "CreditsSentretGFX", "TheEndGFX", "CreditsPalettes",
    # The #DEX diploma (engine/events/diploma.asm PlaceDiplomaOnScreen).
    # DiplomaGFX is gfx/diploma/diploma.2bpp.lz, 112 tiles into vTiles2;
    # DiplomaPage1Tilemap is gfx/diploma/page1.tilemap, a whole SCREEN_AREA
    # copied straight over the background; DiplomaPalettes is gfx/diploma/
    # diploma.pal (engine/gfx/color.asm), which _CGB_Diploma loads before
    # WipeAttrmap puts every tile on set 0.
    "DiplomaGFX", "DiplomaPage1Tilemap", "DiplomaPalettes",
    # The trade animation's art (engine/movie/trade_animation.asm, gfx/trade/).
    # TradeGameBoyLZ is game_boy_cable.2bpp.lz, 49 tiles into vTiles2 tile $31,
    # which both tilemaps and the jumptable's loose cable ids index; the rest
    # are the OAM objects -- the ball, the poof, the tube bulge (gfx/trade/
    # cable.png) and the mon icon's bubble -- plus the two one-tile arrows
    # TradeAnim_PlaceTrademonStatsOnTubeAnim fills across the window.
    "TradeGameBoyLZ", "TradeGameBoyTilemap", "TradeLinkTubeTilemap",
    "TradeBallGFX", "TradePoofGFX", "TradeCableGFX", "TradeBubbleGFX",
    "TradeArrowRightGFX", "TradeArrowLeftGFX",
    # Item names (data/items/names.asm) for giveitem / verbosegiveitem
    "ItemNames",
    # Mart shelves (data/items/marts.asm): Marts is the NUM_MARTS pointer
    # table `pokemart` indexes, each list `db count, items..., -1`.
    # BargainShopData (data/items/bargain_shop.asm) is the Goldenrod
    # Underground shop's own `dbw item, price` rows -- the one shop whose
    # prices do not come from ItemAttributes.
    "Marts", "BargainShopData",
    # GBC colour (engine/gfx/color.asm LoadMapPals).  TilesetBGPalette is the
    # shared pool of $2a four-colour palettes; EnvironmentColorsPointers picks
    # 8 of them per environment per time of day; MapObjectPals recolours OW
    # sprites; RoofPals overrides PAL_BG_ROOF colours 1-2 per map group.
    "TilesetBGPalette", "EnvironmentColorsPointers", "MapObjectPals",
    "RoofPals", "PokemonPalettes", "TrainerPalettes",
    "HPBarPals", "ExpBarPalette", "PartyMenuOBPals",
    # Battle + pokemon runtime data
    "MoveNames", "TypeNames", "TypeMatchups", "TMHMMoves", "GrowthRates",
    "ItemAttributes", "MoveDescriptions", "ItemDescriptions",
    # Wild encounters (data/wild/*)
    "JohtoGrassWildMons", "JohtoWaterWildMons",
    "KantoGrassWildMons", "KantoWaterWildMons",
    "FishGroups", "TimeFishGroups", "TreeMons", "TreeMonMaps",
    # data/wild/treemon_maps.asm RockMonMaps: the four maps whose smashable
    # rocks roll TREEMON_SET_ROCK, read by RockMonEncounter.
    "RockMonMaps",
    # data/wild/swarm_grass.asm + swarm_water.asm, searched by
    # _SwarmWildmonCheck BEFORE the Johto/Kanto tables while the player stands
    # on wSwarmMapGroup/Number, and data/wild/roammon_maps.asm RoamMaps, the
    # graph UpdateRoamMons and JumpRoamMon walk the beasts along.
    "SwarmGrassWildMons", "SwarmWaterWildMons", "RoamMaps",
    # data/wild/bug_contest_mons.asm, which is NOT a grass table: its rows are
    # `db %, species, min, max` with no map key and no time of day, and
    # ChooseWildEncounter_BugContest (engine/overworld/events.asm) walks them
    # with `ld de, 4`.
    "ContestMons",
    # data/events/bug_contest_flags.asm: the ten
    # EVENT_BUG_CATCHING_CONTESTANT_*A words
    # SelectRandomBugContestContestants (engine/events/bug_contest/
    # contest_2.asm) resets and then sets five of, a set flag being what keeps
    # that trainer off NationalParkBugContest.
    "BugCatchingContestantEventFlagTable",
    # Trainers (data/trainers/*)
    "Trainers", "TrainerGroups", "TrainerClassNames",
    "TrainerClassAttributes", "TrainerPicPointers",
    "TrainerEncounterMusic",
    # Menus: party icons, dex ordering + entries, Pokegear landmarks.  The two
    # held-item marker tiles ride on the party icons: GetIconGFX uploads
    # HeldItemIcons straight after each icon's eight tiles
    # (engine/gfx/mon_icons.asm:218-228), so the party list cannot draw the
    # marker without this symbol.
    "MonMenuIcons", "Icons", "IconPointers", "HeldItemIcons",
    "PokedexDataPointerTable",
    # engine/pokemon/bills_pc.asm:2170-2173 (the four vTiles2 $5c tiles
    # PCMonInfo prints at :1086/:1091) and engine/gfx/cgb_layouts.asm:287.
    "PCMailGFX", "BillsPCOrangePalette",
    # New-game / Pokecenter respawn table (data/maps/spawn_points.asm)
    "SpawnPoints",
    "NewPokedexOrder", "AlphabeticalPokedexOrder", "Landmarks",
    # GoldSilverIntro (engine/movie/intro.asm): three acts of BG art, each a
    # compressed tile sheet plus a 2x2 metatile table and a metatile grid, and
    # the OBJ sheets the mons animate from
    "Intro_WaterGFX1", "Intro_WaterTilemap", "Intro_WaterMeta",
    "Intro_WaterGFX2",
    "Intro_GrassGFX1", "Intro_GrassTilemap", "Intro_GrassMeta",
    "Intro_GrassGFX2",
    "Intro_FireGFX1", "Intro_FireGFX2", "Intro_FireGFX3",
    # ...and its palettes.  _CGB_GSIntro carries the water act's inline, takes
    # the grass and starter ones out of PredefPals, and reaches the fire act's
    # through the four PREDEFPAL_* indices inside PalPacket_Pack.
    "PredefPals", "PalPacket_Pack",
    "_CGB_GSIntro.ShellderLaprasBGPalette", "_CGB_GSIntro.ShellderLaprasOBPals",
    "Intro_LoadMagikarpPalettes.MagikarpBGPal",
    "Intro_LoadMagikarpPalettes.MagikarpOBPal",
    # Battle animations (data/moves/animations.asm + data/battle_anims/*).
    # BattleAnimations is a 278-entry pointer table indexed by the animation
    # id, which for a move IS its move id; the four tables under it are the
    # object rows an animation spawns, their framesets, the OAM sets those
    # framesets step through, and the compressed sheets the tiles come from.
    "BattleAnimations", "BattleAnimObjects", "BattleAnimFrameData",
    "BattleAnimOAMData", "AnimObjGFX",
    # gfx/battle_anims/battle_anims.pal: the six OBJ palettes an animation
    # object names by PAL_BATTLE_OB_GRAY..PAL_BATTLE_OB_BROWN.  The routine
    # that copies them (CGBCopyBattleObjectPals) is dummied out on the cart --
    # _CGB_BattleColors loads the same block -- but the label is still in the
    # ROM, so the colours are read rather than transcribed.
    "BattleObjectPals",
    # Battle HUD tiles (engine/gfx/load_font.asm LoadHPBar): the L-shaped
    # frame tiles the enemy and player HUDs are built from, and the exp bar's
    # nine fill cells.  "HP:" and the ten HP-bar cells come from
    # FontBattleExtra, which is already extracted.
    "EnemyHPBarBorderGFX", "HPExpBarBorderGFX", "ExpBarGFX",
    # gfx/battle/balls.2bpp, the four party-ball OAM tiles
    # (engine/battle/trainer_huds.asm LoadBallIconGFX).
    "LoadBallIconGFX.gfx",
    # gfx/stats/stats_tiles.png + gfx/stats/pages.pal, StatsScreen_LoadFont
    # and _CGB_StatsScreenHPPals (#1558)
    "StatsScreenPageTilesGFX", "StatsScreenPagePals",
    # The player's own battle back-pic (gfx/player/chris_back.2bpp.lz).  It is
    # what stands in the player's pic box for the whole battle intro, before
    # SendOutPlayerMon swaps in the mon's backpic.
    "ChrisBackpic",
    # UnownPicPointers: 26 rows of `dba_pics front, back`, one per letter.
    # PokemonPicPointers' UNOWN row is only the A form, so the other 25 are
    # unreachable without this table.
    "UnownPicPointers",
    # Naming screen chrome (gfx/naming_screen/*): the patterned tile that
    # fills the backdrop, the 2-tile cursor, and the middle/under lines that
    # mark the name-entry field
    "NamingScreenGFX_Border", "NamingScreenGFX_Cursor",
    "NamingScreenGFX_MiddleLine", "NamingScreenGFX_UnderLine",
    # PACK chrome (engine/items/pack.asm): the screen's own $60-tile sheet,
    # the four 15-tile pack pictures DrawPackGFX swaps per pocket, the 5x12
    # pocket-name tilemap, and the six BG palettes _CGB_PackPals loads.
    "PackMenuGFX", "PackGFX", "DrawPocketName.tilemap",
    "_CGB_PackPals.PackPals",
    # #DEX chrome (engine/pokedex/pokedex.asm): Pokedex_LoadGFX decompresses
    # PokedexLZ over vTiles2 tile $31, and every frame, divider and label tile
    # the dex screens name comes out of that one sheet.
    # PokedexSlowpokeLZ is the dex's OBJ sheet: the search screen's Slowpoke
    # animation, and behind it the $30-$33 bracket tiles the listing cursor is
    # built from plus the $0f scrollbar thumb.
    "PokedexLZ", "PokedexSlowpokeLZ", "PokedexCursorPalette",
    "LoadQuestionMarkPic.QuestionMarkLZ", "PokedexQuestionMarkPalette",
    "Footprints",
    # POKeGEAR (engine/pokegear/pokegear.asm): TownMapGFX at $00 and
    # PokegearGFX at $30 are the card sheets; the three cards are RLE
    # tilemaps; JohtoMap/KantoMap are the painted town maps; TownMapPals.PalMap
    # is the tile-id -> BG palette nybble table that colours them.
    "TownMapGFX", "PokegearGFX", "PokegearSpritesGFX",
    "ClockTilemapRLE", "PhoneTilemapRLE", "RadioTilemapRLE",
    "JohtoMap", "KantoMap", "TownMapPals.PalMap", "PokegearPals",
    # Trainer card (engine/menus/trainer_card.asm): the player's portrait and
    # the card frame share one sheet at $00, the status/leader sheets both
    # load at $29, and the badges are OBJs with their own OAM template table.
    "ChrisPicAndTrainerCardGFX", "CardStatusGFX", "LeaderGFX", "BadgeGFX",
    "TrainerCard_JohtoBadgesOAM",
    # Unown puzzle (engine/games/unown_puzzle.asm): the four pictures the Ruins
    # of Alph chambers slice into sixteen panels, the START>CANCEL box and
    # caption sheet that lands at vTiles0 $ed, the four cursor OBJ tiles at
    # $e0, and the eight border tiles UnownPuzzle_AddPuzzlePieceBorders ORs
    # onto every panel once the picture has been doubled in size.
    "KabutoPuzzleLZ", "OmanytePuzzleLZ", "AerodactylPuzzleLZ", "HoOhPuzzleLZ",
    "UnownPuzzleStartCancelLZ", "UnownPuzzleCursorGFX",
    "PuzzlePieceBorderData.TileBordersGFX",
    # Goldenrod Game Corner (engine/games/slot_machine.asm + card_flip.asm).
    # Slots1LZ/2LZ/3LZ are the reel + actor sheets; SlotsTilemap is the 20x12
    # BG map.  CardFlipLZ01..03 + On/Off button tiles and CardFlipTilemap are
    # the odds-board art.  Without these in the manifest the extractor skips
    # the files and SlotMachine/CardFlip fall back to labelled cells.
    "Slots1LZ", "Slots2LZ", "Slots3LZ", "SlotsTilemap",
    "CardFlipLZ01", "CardFlipLZ02", "CardFlipLZ03",
    "CardFlipOnButtonGFX", "CardFlipOffButtonGFX", "CardFlipTilemap",
    # Emote bubbles (data/sprites/emotes.asm): showemote's ! over a trainer
    # who just spotted the player, and the other faces scripts use.
    "ShockEmote", "QuestionEmote", "HappyEmote", "SadEmote",
    "HeartEmote", "BoltEmote", "SleepEmote", "FishEmote",
    # gfx/overworld/chris_fish.2bpp (engine/events/fishing_gfx.asm:23): the
    # fishing pose rows and the rod tiles FacingFish* parks by the player.
    "FishingGFX",
    # The Pokecenter heal machine's OBJ art (engine/events/
    # heal_machine_anim.asm): two tiles -- the machine's light ($7c) and the
    # ball ($7d) -- plus the CGB palette .LoadPalettes copies over
    # PAL_OW_TREE for the duration of the light show.
    "HealMachineAnim.HealMachineGFX", "HealMachineAnim.palettes",
    # Magnet Train (engine/events/magnet_train.asm).  Two uncompressed
    # tilemaps: a 2x18 strip DrawMagnetTrain repeats across the background,
    # and the 20x4 train laid over rows 6-9.  The cutscene loads no tiles of
    # its own -- both index TILESET_TRAIN_STATION, already in VRAM.
    "MagnetTrainBGTiles", "MagnetTrainTilemap",
    # callstd / jumpstd targets (engine/events/std_scripts.asm)
    "StdScripts",
    # The phone (data/phone/*.asm).  PhoneContacts is a row per PHONE_*
    # constant carrying two `dba` script pointers -- the callee half (you rang
    # them) and the caller half (they rang you) -- and SpecialPhoneCallList a
    # row per SPECIALCALL_* carrying one more.  Every one of those pointers
    # lands in bank $41, which no map script points at, so without these two
    # symbols the whole bank stays unreachable and a queued call has no body.
    "PhoneContacts", "SpecialPhoneCallList",
    # The three little scripts the phone engine runs WITHOUT a contact row
    # (engine/phone/phone.asm): the wrong-number arm LoadCallerScript falls to,
    # the no-signal / wrong-hour arm, and "they are on this very map, go talk
    # to them".  Nothing points at any of them either.
    "WrongNumber.script", "PhoneOutOfAreaScript", "PhoneScript_JustTalkToThem",
    # Egg moves (data/pokemon/egg_moves.asm): 251 `dw` into bank 8, each a
    # $ff-terminated move list.  Breeding.canInheritMove reads def.eggMoves.
    "EggMovePointers",
    # In-game trades (data/events/npc_trades.asm), the `trade` command's table,
    # and the 5x3 text table PrintTradeText indexes by dialog then dialog SET
    # (engine/events/npc_trade.asm).  NPCTradeCableText is the "cable" line
    # between the yes and the animation; TradedForText is the one after it.
    "NPCTrades", "TradeTexts", "NPCTradeCableText", "TradedForText",
    # The six lines the trade ANIMATION prints around the swap
    # (engine/movie/trade_animation.asm, strings in data/text/common_1.asm).
    # They are printed by asm, not by a writetext, so the text walker only
    # reaches them by name -- and their {STRBUF} markers are the trademon
    # name buffers, which is the only thing that tells "MACHOP was sent to
    # MIKE" from the same line with the two names the other way round.
    # _MonNameSentToText is deliberately absent: it is empty.
    "_MonWasSentToText", "_ForYourMonSendsText", "_OTSendsText",
    "_BidsFarewellToMonText", "_MonNameBidsFarewellText",
    "_TakeGoodCareOfMonText",
    # Elevator floor labels (data/events/elevator_floors.asm).  The floor LIST
    # an `elevator` names lives in the script's own bank; this is the shared
    # FLOOR_* -> "B1F@" name table the ride menu prints.
    "ElevatorFloorNames",
    # describedecoration's five DECODESC_* arms (engine/overworld/decorations.asm).
    # Each arm is asm that picks a SCRIPT off what is installed in the player's
    # room, so the scripts are what the extractor wants, not the arms:
    # DecorationDesc_PosterPointers is the poster table (`dbw deco, script`
    # rows, `db -1` end) with NullPoster as its miss; the two ornaments and the
    # console all share .OrnamentConsoleScript, and the giant ornament has
    # .BigDollScript to itself.
    "DecorationDesc_PosterPointers", "DecorationDesc_NullPoster",
    "DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript",
    "DecorationDesc_GiantOrnament.BigDollScript",
    # Text an ENGINE routine prints rather than a script (RomExtractorGen2's
    # NAMED_TEXT).  The text walker only follows a writetext pointer, so
    # without these labels the Day-Care and breeding block of common_1.asm /
    # common_2.asm, the whole POKeMART conversation, and the Hall of Fame's
    # three flavour strings never reach the cache at all.
    "_DaycareDummyText",
    "_DayCareManIntroText", "_DayCareManIntroEggText",
    "_DayCareLadyIntroText", "_DayCareLadyIntroEggText",
    "_WhatShouldIRaiseText", "_OnlyOneMonText", "_CantAcceptEggText",
    "_RemoveMailText", "_LastHealthyMonText", "_IllRaiseYourMonText",
    "_ComeBackLaterText", "_AreWeGeniusesText", "_YourMonHasGrownText",
    "_PerfectHeresYourMonText", "_GotBackMonText", "_BackAlreadyText",
    "_HaveNoRoomText", "_NotEnoughMoneyText", "_OhFineThenText",
    "_ComeAgainText", "_NotYetText", "_FoundAnEggText", "_ReceivedEggText",
    "_TakeGoodCareOfEggText", "_IllKeepItThanksText", "_NoRoomForEggText",
    "Text_BreedHuh", "_BreedClearboxText", "_BreedEggHatchText",
    "_BreedAskNicknameText",
    "_LeftWithDayCareManText", "_LeftWithDayCareLadyText",
    "_BreedBrimmingWithEnergyText", "_BreedNoInterestText",
    "_BreedAppearsToCareForText", "_BreedFriendlyText",
    "_BreedShowsInterestText",
    "_MartWelcomeText", "_MartAskMoreText", "_MartComeAgainText",
    "_MartHowManyText", "_MartFinalPriceText", "_MartThanksText",
    "_MartNoMoneyText", "_MartPackFullText",
    "_HerbShopLadyIntroText", "_HerbalLadyHowManyText",
    "_HerbalLadyFinalPriceText", "_HerbalLadyThanksText",
    "_HerbalLadyPackFullText", "_HerbalLadyNoMoneyText",
    "_HerbalLadyComeAgainText",
    "_BargainShopIntroText", "_BargainShopFinalPriceText",
    "_BargainShopThanksText", "_BargainShopPackFullText",
    "_BargainShopSoldOutText", "_BargainShopNoFundsText",
    "_BargainShopComeAgainText",
    "_PharmacyIntroText", "_PharmacyHowManyText", "_PharmacyFinalPriceText",
    "_PharmacyThanksText", "_PharmacyPackFullText", "_PharmacyNoMoneyText",
    "_PharmacyComeAgainText",
    "_NothingToSellText", "_MartSellHowManyText", "_MartSellPriceText",
    "_MartCantBuyText", "_MartBoughtText",
    "AnimateHallOfFame.String_NewHallOfFamer",
    "_HallOfFamePC.TimeFamer", "_HallOfFamePC.HOFMaster",
    # The MAIL block of data/text/common_2.asm, printed by engine/pokemon/
    # mail.asm's MailboxPC and engine/pokemon/mon_menu.asm's MonMailAction.
    # Same shape as the Day-Care block: asm prints these, so no bytecode
    # points at them and the walker has to be seeded by name.
    "_EmptyMailboxText", "_MailClearedPutAwayText", "_MailPackFullText",
    "_MailMessageLostText", "_MailAlreadyHoldingItemText", "_MailEggText",
    "_MailMovedFromBoxText", "_MailLoseMessageText", "_MailDetachedText",
    "_MailNoSpaceText", "_MailAskSendToPCText", "_MailboxFullText",
    "_MailSentToPCText", "_PCMonHoldingMailText", "_PokemonRemoveMailText",
}


# The engine's own text, the counterpart to make_rom_manifest.text_metadata.
#
# Only these five carry dialogue.  data/text/'s other files are character
# tables rather than strings: dakutens.asm and name_input_chars.asm /
# mail_input_chars.asm are keyboard layouts, and unused_gen1_trainer_names.asm
# is a dead Gen 1 leftover.  Decoding those as text yields keyboard rows and
# kana runs, so they are left out by name rather than filtered afterwards.
#
# None of the five carries an IF DEF(_GOLD) / IF DEF(_SILVER) arm, so the
# label set is one list for both editions and make_silver_manifest.py inherits
# it with the addresses re-resolved from pokesilver.sym.
TEXT_SOURCES = (
    "battle.asm",
    "common_1.asm",
    "common_2.asm",
    "common_3.asm",
    "std_text.asm",
)


def text_labels(pokegold, defines=None):
    """Every text label in TEXT_SOURCES, in sorted order.

    Unlike Gen 1 there is no `dynamic` map beside this.  pokered's decoder is
    told which runtime token each label carries; RomExtractorGen2's reads the
    cart's own TX_RAM / TX_DECIMAL command bytes and emits {STRBUF} / {NUM}
    itself, so the label alone is enough.
    """
    labels = set()
    for name in TEXT_SOURCES:
        path = os.path.join(pokegold, "data/text", name)
        pending = None
        for _, line in read_asm(path, defines):
            stripped = line.strip()
            if not stripped:
                continue
            match = re.match(r"(\w+)::?\s*$", stripped)
            if match:
                # A label whose next line is another label owns no string of
                # its own.  `BattleText::` is the one in this set: its own
                # comment says "used only for BANK(BattleText)", and it shares
                # an address with the first real label under it, so taking it
                # would decode that neighbour's string a second time.
                pending = match.group(1)
                continue
            if pending:
                labels.add(pending)
                pending = None
    return sorted(labels)


def embedded_symbols(symbols, pokemon_labels, song_labels=(),
                     text_label_names=(), required=None):
    """Resolve REQUIRED_SYMBOLS + pic labels + songs + text labels."""
    if required is None:
        required = REQUIRED_SYMBOLS
    names = (set(required) | set(pokemon_labels)
             | set(song_labels) | set(text_label_names))
    for symbol_name in symbols.by_name:
        # Pokedex entries are split across four banks and the game derives the
        # bank arithmetically from the species id (radio.asm's rlca/maskbits
        # dance).  Taking each entry's own symbol instead means the extractor
        # never has to reproduce that, and a repointed entry still resolves.
        if symbol_name.endswith("PokedexEntry") and "." not in symbol_name:
            names.add(symbol_name)
        if symbol_name.endswith("Frontpic") or symbol_name.endswith("Backpic"):
            # Skip qualified locals (e.g. "Foo.BarBackpic") -- those belong
            # to unrelated engine routines, not a Pic Pointers table entry.
            if "." not in symbol_name:
                names.add(symbol_name)

    missing = sorted(name for name in names if name not in symbols.by_name)
    if missing:
        raise ValueError("required symbols are missing: " + ", ".join(missing))
    return {
        name: [symbols[name].bank, symbols[name].address]
        for name in sorted(names)
    }


def generate(pokegold, symbols_path, defines=None, required=None,
             sha1=None, anim_labels=False):
    symbols = SymbolTable(symbols_path)

    species = parse_const_block(
        os.path.join(pokegold, "constants/pokemon_constants.asm"),
        stop_at="NUM_POKEMON", defines=defines)
    species_order = [n or "UNUSED" for n in species[1:]]

    map_order, map_groups = extract_map_groups(pokegold, defines)

    tilesets = [n for n in parse_const_block(
        os.path.join(pokegold, "constants/tileset_constants.asm"),
        stop_at="NUM_TILESETS", defines=defines) if n]

    moves = parse_const_block(
        os.path.join(pokegold, "constants/move_constants.asm"),
        stop_at="NUM_ATTACKS", defines=defines)
    move_order = [n or "UNUSED" for n in moves[1:]]

    # sprite_constants.asm stacks the ids of two different tables.  $01..
    # NUM_OVERWORLD_SPRITES are OverworldSprites rows (data/sprites/
    # sprites.asm); then `const_next $80` restarts at SPRITE_POKEMON and the
    # names from there are SpriteMons rows (data/sprites/sprite_mons.asm),
    # which GetMonSprite turns into a mon's menu icon instead of a sheet.
    # Both blocks are read into one id-indexed list, because a byte in
    # wVariableSprites -- what a doll or a Sudowoodo stands on -- is a raw
    # sprite id and the port names it through this list.
    #
    # It stops at SPRITE_HO_OH on purpose.  SPRITE_DAY_CARE_MON_1/_2 ($e0) and
    # the SPRITE_VARS block ($f0) past it are not sprites at all: the first
    # pair reads a breedmon species and the second is a slot INTO
    # wVariableSprites, so naming them here would let World:resolveSprite hand
    # a slot id back as though it were something that could be drawn.
    sprite_path = os.path.join(pokegold, "constants/sprite_constants.asm")
    sprites = parse_const_block(
        sprite_path, stop_at="NUM_POKEMON_SPRITES", defines=defines)
    sprite_order = [n or "UNUSED" for n in sprites[1:]]
    # The two DEFs the block itself ends its halves on.  The $60..$7f hole
    # between them stays in sprite_order as UNUSED rows so the ids line up.
    num_overworld_sprites = len(
        parse_const_block(
            sprite_path, stop_at="NUM_OVERWORLD_SPRITES",
            defines=defines)) - 1
    sprite_pokemon = sprites.index("SPRITE_UNOWN")
    if len(sprites) - sprite_pokemon != 35:
        raise SystemExit(
            f"{sprite_path}: expected 35 SpriteMons ids, got "
            f"{len(sprites) - sprite_pokemon}")

    types = extract_types(pokegold, defines)

    # Environment (1-based), palette (0-based), fish-group (0-based) name
    # tables -- scraped by prefix so the mixed const_def blocks in
    # map_data_constants.asm cannot collide with each other.
    environments, palettes, fish_groups, spawns = [], [], [], []
    path = os.path.join(pokegold, "constants/map_data_constants.asm")
    for _, line in read_asm(path, defines):
        s = line.strip()
        m = re.match(r"const\s+(\w+)", s)
        if not m:
            continue
        name = m.group(1)
        if name in (
            "TOWN", "ROUTE", "INDOOR", "CAVE", "ENVIRONMENT_5", "GATE",
            "DUNGEON",
        ) or name.startswith("ENVIRONMENT_"):
            environments.append(name)
        elif name.startswith("PALETTE_"):
            palettes.append(name)
        elif name.startswith("FISHGROUP_"):
            fish_groups.append(name)
        elif name.startswith("SPAWN_"):
            spawns.append(name)

    # Move effects (EFFECT_*) index the effect jumptable; the extractor turns
    # Moves' effect byte into one of these names so the battle engine can
    # switch on a readable id instead of a raw number.
    move_effects = parse_const_block(
        os.path.join(pokegold, "constants/move_effect_constants.asm"),
        defines=defines)
    move_effect_order = [n or "EFFECT_UNUSED" for n in move_effects]

    # Battle animations.  constants/battle_anim_constants.asm stacks nine
    # unrelated const_def blocks in one file, so each list is scraped by its
    # own prefix -- parse_const_block would run them together.  These name the
    # rows of the five tables the extractor reads (objects, functions,
    # framesets, OAM sets, GFX sheets) plus the BG-effect and palette enums a
    # disassembled animation refers to by number.
    battle_anim = os.path.join(pokegold, "constants/battle_anim_constants.asm")
    battle_anim_objects = parse_prefixed_consts(
        battle_anim, ("BATTLE_ANIM_OBJ_",), defines=defines)
    battle_anim_funcs = parse_prefixed_consts(
        battle_anim, ("BATTLE_ANIM_FUNC_",), defines=defines)
    battle_anim_framesets = parse_prefixed_consts(
        battle_anim, ("BATTLE_ANIM_FRAMESET_",), defines=defines)
    battle_anim_oamsets = parse_prefixed_consts(
        battle_anim, ("BATTLE_ANIM_OAMSET_",), defines=defines)
    # BATTLE_ANIM_GFX_* is the one block here that does not start at zero
    # (`const_def 1`, because AnimObjGFX row 0 is the empty AnimObj00GFX).
    # A placeholder in front keeps every list in this group indexable as
    # value + 1, so the extractor never has to remember which is which.
    battle_anim_gfx = ["BATTLE_ANIM_GFX_NONE"] + parse_prefixed_consts(
        battle_anim, ("BATTLE_ANIM_GFX_",), defines=defines)
    battle_bg_effects = parse_prefixed_consts(
        battle_anim, ("BATTLE_BG_EFFECT_",), defines=defines)
    # Two separate blocks, each starting at zero: a BG palette 4 and an OBJ
    # palette 4 are different colours, so they cannot share one list.
    battle_anim_bg_pals = parse_prefixed_consts(
        battle_anim, ("PAL_BATTLE_BG_",), defines=defines)
    battle_anim_ob_pals = parse_prefixed_consts(
        battle_anim, ("PAL_BATTLE_OB_",), defines=defines)

    item_data = os.path.join(pokegold, "constants/item_data_constants.asm")
    # Pocket ids are 0-based (ITEM, KEY_ITEM, BALL, TM_HM); the ITEM_* /
    # ITEMMENU_* / HELD_* blocks share the file, hence the prefix scrape.
    pocket_order = parse_prefixed_consts(
        item_data, (), exact=("ITEM", "KEY_ITEM", "BALL", "TM_HM"),
        defines=defines)
    item_menu_order = parse_prefixed_consts(
        item_data, ("ITEMMENU_",), defines=defines)
    # The HELD_* block is sparse (const_skip / const_next), so the index of
    # this list must BE the ItemAttributes effect byte or every held effect
    # past HELD_CLEANSE_TAG lands on the wrong item.
    held_effect_order = parse_sparse_consts(
        item_data, ("HELD_",), defines=defines)

    mon_data = os.path.join(pokegold, "constants/pokemon_data_constants.asm")
    growth_order = parse_prefixed_consts(mon_data, ("GROWTH_",), defines=defines)
    egg_group_order = parse_prefixed_consts(mon_data, ("EGG_",), defines=defines)
    evolve_order = parse_prefixed_consts(mon_data, ("EVOLVE_",), defines=defines)

    # Map callbacks (constants/map_setup_constants.asm).  That block is
    # `const_def 1`, so MAPCALLBACK_TILES is 1 and index 0 of this list is a
    # placeholder -- the same "value + 1" indexing BATTLE_ANIM_GFX_* uses.
    map_setup = os.path.join(pokegold, "constants/map_setup_constants.asm")
    map_callback_order = ["MAPCALLBACK_NONE"] + parse_prefixed_consts(
        map_setup, ("MAPCALLBACK_",), defines=defines)

    # constants/script_constants.asm stacks a dozen unrelated blocks, so each
    # of these is found by the name its own block opens with.
    script_consts = os.path.join(pokegold, "constants/script_constants.asm")
    cmd_queue_order = parse_const_block_at(
        script_consts, "CMDQUEUE_NULL", stop_at="NUM_CMDQUEUE_TYPES",
        defines=defines)
    floor_order = parse_const_block_at(
        script_consts, "FLOOR_B4F", stop_at="NUM_FLOORS", defines=defines)
    deco_desc_order = parse_const_block_at(
        script_consts, "DECODESC_POSTER", stop_at="NUM_DECODESCS",
        defines=defines)

    # The phone.  PHONE_* doubles as the PhoneContacts row index and carries
    # four const_skip holes that are real rows (the wrong-number fillers), so
    # the gaps have to survive into the list.
    phone_consts = os.path.join(pokegold, "constants/phone_constants.asm")
    # The holes are named rather than left null: the JSON decoder the importer
    # runs on this file collapses nulls out of an array, which would slide
    # every contact past a hole four rows down its own table.
    phone_contact_order = [
        n or "PHONE_UNUSED" for n in parse_const_block_at(
            phone_consts, "PHONE_00", stop_at="NUM_PHONE_CONTACTS",
            defines=defines)]
    special_call_order = parse_const_block_at(
        phone_consts, "SPECIALCALL_NONE", stop_at="NUM_SPECIALCALLS",
        defines=defines)

    npc_trade = os.path.join(pokegold, "constants/npc_trade_constants.asm")
    trade_gender_order = parse_prefixed_consts(
        npc_trade, ("TRADE_GENDER_",), defines=defines)
    trade_dialog_order = parse_prefixed_consts(
        npc_trade, ("TRADE_DIALOGSET_",), defines=defines)

    trainer_classes, trainer_members = extract_trainer_classes(
        pokegold, defines)
    trainer_types = parse_prefixed_consts(
        os.path.join(pokegold, "constants/trainer_data_constants.asm"),
        ("TRAINERTYPE_",), defines=defines)

    landmarks = parse_const_block(
        os.path.join(pokegold, "constants/landmark_constants.asm"),
        stop_at="NUM_LANDMARKS", defines=defines)
    landmark_order = [n or "UNUSED" for n in landmarks]

    icons = parse_prefixed_consts(
        os.path.join(pokegold, "constants/icon_constants.asm"), ("ICON_",),
        defines=defines)

    tree_sets = parse_prefixed_consts(
        mon_data, ("TREEMON_SET_",), defines=defines)

    std_scripts = extract_std_scripts(pokegold, defines)
    specials = extract_specials(pokegold, defines)

    assets = pokemon_assets(pokegold, species_order, symbols, defines,
                            anim_labels=anim_labels)
    pokemon_labels = []
    for asset in assets.values():
        if asset["frontLabel"]:
            pokemon_labels.append(asset["frontLabel"])
        if asset["backLabel"]:
            pokemon_labels.append(asset["backLabel"])

    songs = music_order(pokegold, defines)
    text_label_names = text_labels(pokegold, defines)
    sfx = sfx_order(pokegold, defines)

    # Index 0 is NO_ITEM, so the parsed list is already 1-based on item id.
    # ItemNames only has rows for the first `item_name_count` of them; the TM
    # and HM items past that are named from their TM number instead.
    item_order, item_name_count = extract_items(pokegold, defines)

    data = {
        "format": 3,
        "generation": 2,
        "romSha1": sha1 or CANONICAL_GOLD_SHA1,
        "constants": {
            "source": "pret/pokegold constants/*.asm",
            "speciesOrder": species_order,
            "mapGroups": [map_groups[name] for name in map_order],
            "mapOrder": map_order,
            "tilesetOrder": tilesets,
            "moveOrder": move_order,
            "types": types,
            "spriteOrder": sprite_order,
            # NUM_OVERWORLD_SPRITES: how many leading spriteOrder rows are real
            # OverworldSprites rows.  Everything from spritePokemon on names a
            # SpriteMons row and has no sheet of its own.
            "numOverworldSprites": num_overworld_sprites,
            # SPRITE_POKEMON ($80): the first mon-icon id, and the base
            # GetMonSprite subtracts to index SpriteMons.
            "spritePokemon": sprite_pokemon,
            "environmentOrder": environments,
            "paletteOrder": palettes,
            "fishGroupOrder": fish_groups,
            # SPAWN_* order; SPAWN_HOME is a new game's start (intro_menu.asm)
            "spawnOrder": spawns,
            # MUSIC_* id → Music_* label (audio/music_pointers.asm order)
            "musicOrder": songs,
            # SFX_* id → Sfx_* label (audio/sfx_pointers.asm order)
            "sfxOrder": sfx,
            # Item id → constant name (MASTER_BALL=1 …); used by extractItems
            "itemOrder": item_order,
            # How many leading item ids ItemNames has rows for; TM/HM items
            # past this point carry no name of their own.
            "itemNameCount": item_name_count,
            # Battle / pokemon runtime orders
            "moveEffectOrder": move_effect_order,
            "growthRateOrder": growth_order,
            "eggGroupOrder": egg_group_order,
            "evolveMethodOrder": evolve_order,
            # Bag pockets + item attribute enums (data/items/attributes.asm).
            # `property` is a bitfield, not an enum (shift_const CANT_SELECT
            # = bit 6, CANT_TOSS = bit 7), so the extractor decodes it
            # directly rather than naming it from a list here.
            "pocketOrder": pocket_order,
            "itemMenuOrder": item_menu_order,
            "heldEffectOrder": held_effect_order,
            # Trainers: class id → name, class → its own trainer ids
            "trainerClassOrder": trainer_classes,
            "trainerClassMembers": trainer_members,
            "trainerTypeOrder": trainer_types,
            # Pokegear town map + wild encounter side tables
            "landmarkOrder": landmark_order,
            "iconOrder": icons,
            "treeMonSetOrder": tree_sets,
            # callstd / jumpstd id → StdScripts label
            "stdScriptOrder": std_scripts,
            # Map script header: callback type id → MAPCALLBACK_* name (1-based,
            # index 0 is a placeholder) and the cmdqueue entry's own type enum.
            "mapCallbackOrder": map_callback_order,
            "cmdQueueOrder": cmd_queue_order,
            # `elevator` floor ids and `describedecoration`'s five arms
            "floorOrder": floor_order,
            "decoDescOrder": deco_desc_order,
            # PhoneContacts row → PHONE_* name (holes kept: they are rows),
            # and SpecialPhoneCallList row → SPECIALCALL_* name
            "phoneContactOrder": phone_contact_order,
            "specialCallOrder": special_call_order,
            # `trade` row enums (data/events/npc_trades.asm)
            "tradeGenderOrder": trade_gender_order,
            "tradeDialogOrder": trade_dialog_order,
            # `special` id → SpecialsPointers label
            "specialOrder": specials,
            # Battle animations: the row names of BattleAnimObjects,
            # BattleAnimFrameData, BattleAnimOAMData and AnimObjGFX, plus the
            # BG-effect and OBJ/BG palette enums an animation names by number.
            "battleAnimObjectOrder": battle_anim_objects,
            "battleAnimFuncOrder": battle_anim_funcs,
            "battleAnimFramesetOrder": battle_anim_framesets,
            "battleAnimOamsetOrder": battle_anim_oamsets,
            "battleAnimGfxOrder": battle_anim_gfx,
            "battleBgEffectOrder": battle_bg_effects,
            "battleAnimBgPaletteOrder": battle_anim_bg_pals,
            "battleAnimObPaletteOrder": battle_anim_ob_pals,
        },
        # Label -> decoded string is built at import time from these, the
        # same way Gen 1 builds data/generated/text.lua from its own list.
        "text": {"labels": text_label_names},
        "charmap": charmap(pokegold, defines),
        "fontCharmap": font_extract.parse_charmap(pokegold),
        "pokemonAssets": assets,
        # Per-map metadata (group/map/width/height/name).  RomExtractorGen2
        # resolves ROM headers via MapGroupPointers + these ids.
        "maps": {name: map_groups[name] for name in map_order},
        "tilesets": {name: {} for name in tilesets},
    }
    data["symbols"] = embedded_symbols(
        symbols, pokemon_labels, songs, text_label_names, required=required)
    return data


def find_pokegold():
    for candidate in DEFAULT_POKEGOLD_CANDIDATES:
        if os.path.isfile(os.path.join(candidate, "main.asm")):
            return candidate
    return DEFAULT_POKEGOLD_CANDIDATES[-1]


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--pokegold", default=find_pokegold())
    parser.add_argument("--symbols", default=DEFAULT_SYMBOLS)
    parser.add_argument(
        "--out",
        default=os.path.join(
            os.path.dirname(__file__), "rom_manifest_gold.json"))
    args = parser.parse_args()

    pokegold = os.path.abspath(args.pokegold)
    if not os.path.isfile(os.path.join(pokegold, "main.asm")):
        raise SystemExit(f"{pokegold} is not a pokegold checkout")
    data = generate(pokegold, os.path.abspath(args.symbols))
    with open(args.out, "w", encoding="utf-8", newline="\n") as f:
        json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"wrote {args.out}")


if __name__ == "__main__":
    main()
