mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-02 07:50:13 -04:00
332 lines
12 KiB
Python
332 lines
12 KiB
Python
"""Generate src/import/gba/versions_text*.lua from matching pret ELF symbols and sources.
|
|
|
|
python3 tools/gen_rom_text_tables.py [../pokefirered]
|
|
python3 tools/gen_rom_text_tables.py --repo ../pokeemerald --game emerald [--out PATH] [-v]
|
|
"""
|
|
import os, re, subprocess, sys
|
|
from pathlib import Path
|
|
|
|
B = 0x8000000
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
GAMES = {
|
|
"firered": {
|
|
"repo": "pokefirered",
|
|
"elfs": ["pokefirered.elf", "pokeleafgreen.elf"],
|
|
"out": "src/import/gba/versions_text.lua",
|
|
"mode": "addr",
|
|
},
|
|
"emerald": {
|
|
"repo": "pokeemerald",
|
|
"elfs": ["pokeemerald.elf"],
|
|
"out": "src/import/gba/versions_text_emerald.lua",
|
|
"mode": "names",
|
|
},
|
|
}
|
|
|
|
|
|
def parse_args(argv):
|
|
opts = {"game": "firered", "repo": None, "out": None, "verbose": False}
|
|
i = 0
|
|
while i < len(argv):
|
|
a = argv[i]
|
|
if a in ("--game", "--repo", "--out"):
|
|
opts[a[2:]] = argv[i + 1]
|
|
i += 2
|
|
continue
|
|
if a == "-v":
|
|
opts["verbose"] = True
|
|
elif not a.startswith("-") and opts["repo"] is None:
|
|
opts["repo"] = a
|
|
else:
|
|
sys.exit("unknown argument " + a)
|
|
i += 1
|
|
if opts["game"] not in GAMES:
|
|
sys.exit("unknown --game " + opts["game"])
|
|
return opts
|
|
|
|
|
|
OPTS = parse_args(sys.argv[1:])
|
|
GAME = GAMES[OPTS["game"]]
|
|
NAMES_MODE = GAME["mode"] == "names"
|
|
pret = Path(OPTS["repo"]) if OPTS["repo"] else ROOT.parent / GAME["repo"]
|
|
|
|
|
|
def symbols(path):
|
|
out = {}
|
|
obj = ""
|
|
for line in subprocess.check_output(["arm-none-eabi-readelf", "-sW", str(path)], text=True).splitlines():
|
|
p = line.split()
|
|
if len(p) < 8 or not p[0][:-1].isdigit():
|
|
continue
|
|
_, v, size, typ, bind, _vis, _ndx, name = p[:8]
|
|
if typ == "FILE":
|
|
obj = name
|
|
continue
|
|
a = int(v, 16)
|
|
if typ in ("OBJECT", "NOTYPE") and not name.startswith(("$", ".")) and B <= a < B + 0x1000000:
|
|
out.setdefault(name, []).append((obj if bind == "LOCAL" else "", a, int(size, 0)))
|
|
return out
|
|
|
|
|
|
elfs = [symbols(pret / e) for e in GAME["elfs"]]
|
|
primary = elfs[0]
|
|
|
|
|
|
def unique(name):
|
|
rows = primary.get(name) or []
|
|
if len(rows) != 1:
|
|
return None
|
|
obj = rows[0][0]
|
|
for other in elfs[1:]:
|
|
if not any(r[0] == obj for r in other.get(name) or []):
|
|
return None
|
|
return rows[0]
|
|
|
|
|
|
STRLIT = r'"(?:[^"\\\n]|\\.)*"'
|
|
C_TEXT = re.compile(r"(?:const\s+u8|static\s+const\s+u8|u8\s+const)\s+(?:ALIGNED\(\d\)\s+)?(\w+)\s*\[\s*\]\s*=\s*_\(")
|
|
DENY = (
|
|
"data/maps/", "src/data/pokemon/", "src/move_descriptions.c", "src/data/easy_chat/",
|
|
"src/data/text/abilities.h", "src/data/text/nature_names.h", "src/data/items.h",
|
|
"src/data/decoration/", "src/data/region_map/", "src/berry.c", "src/data/trainers.h",
|
|
"src/trainer_tower_sets.c", "src/data/text/species_names.h", "src/data/text/move_names.h",
|
|
"src/data/text/trainer_class_names.h",
|
|
)
|
|
if NAMES_MODE:
|
|
DENY = DENY + ("src/data/text/move_descriptions.h", "src/data/text/item_descriptions.h")
|
|
|
|
EXTRA_NAMED = {
|
|
"emerald": ("InsideOfTruck_Text_BoxPrintedWithMonLogo",),
|
|
}
|
|
|
|
sources = {}
|
|
text_labels = set()
|
|
for root, _dirs, files in os.walk(pret):
|
|
if "/.git" in root or "/tools" in root or "/build" in root:
|
|
continue
|
|
for f in files:
|
|
path = os.path.join(root, f)
|
|
rel = os.path.relpath(path, pret)
|
|
denied = rel.startswith(DENY)
|
|
if denied and not NAMES_MODE:
|
|
continue
|
|
if f.endswith((".c", ".h")) and rel.startswith("src"):
|
|
body = open(path, encoding="utf-8", errors="replace").read()
|
|
for m in C_TEXT.finditer(body):
|
|
text_labels.add(m.group(1))
|
|
if not denied:
|
|
sources.setdefault(m.group(1), set()).add(rel)
|
|
elif f.endswith((".inc", ".s")) and rel.startswith("data"):
|
|
label = None
|
|
for line in open(path, encoding="utf-8", errors="replace"):
|
|
lm = re.match(r"^\s*(\w+)::?\s*$", line)
|
|
if lm:
|
|
label = lm.group(1)
|
|
continue
|
|
if label and re.match(r"^\s*\.string\s+" + STRLIT, line):
|
|
text_labels.add(label)
|
|
if not denied or label in EXTRA_NAMED.get(OPTS["game"], ()):
|
|
sources.setdefault(label, set()).add(rel)
|
|
label = None
|
|
elif not re.match(r"^\s*$", line):
|
|
label = None
|
|
|
|
battle_names = set()
|
|
for rel in ("src/battle_message.c",):
|
|
body = (pret / rel).read_text(encoding="utf-8", errors="replace")
|
|
for m in C_TEXT.finditer(body):
|
|
text = re.match(r"((?:\s*" + STRLIT + r")+)", body[m.end():])
|
|
if text and "{B_" in text.group(1):
|
|
battle_names.add(m.group(1))
|
|
|
|
|
|
def uses_battle_codes(name):
|
|
return name in battle_names
|
|
|
|
|
|
named, battle, skipped = {}, {}, []
|
|
for name in sorted(sources):
|
|
row = unique(name)
|
|
if not row:
|
|
skipped.append(name)
|
|
continue
|
|
target = battle if uses_battle_codes(name) else named
|
|
target[name] = row[1] - B
|
|
|
|
POINTER_TABLE = re.compile(r"(?:static\s+)?const\s+u8\s*\*\s*const\s+(\w+)\s*\[[^\]\n]*\]\s*(\[[^\]\n]*\])?\s*=")
|
|
TABLE_DENY = {
|
|
"gMonFootprintTable", "gItemEffectTable", "gMonIconTable", "gMoveDescriptionPointers",
|
|
"gAbilityDescriptionPointers", "sMapNames", "gRegionMapEntries", "sPartyMenuActions",
|
|
"sAcceptedActivityIds", "sKeyboardTextColors", "sTimeColonTextColors", "sMoveEffectBS_Ptrs",
|
|
"sPlaceholderPlayerNames",
|
|
}
|
|
TABLE_SOURCES = []
|
|
for root, _dirs, files in os.walk(pret / "src"):
|
|
for f in sorted(files):
|
|
if f.endswith((".c", ".h")):
|
|
TABLE_SOURCES.append(Path(root) / f)
|
|
|
|
tables = []
|
|
seen_tables = set()
|
|
|
|
text_addrs = set()
|
|
other_addrs = set()
|
|
if NAMES_MODE:
|
|
for name, rows in primary.items():
|
|
for _obj, a, _size in rows:
|
|
(text_addrs if name in text_labels else other_addrs).add(a)
|
|
other_addrs -= text_addrs
|
|
rom_bytes = (pret / GAME["elfs"][0].replace(".elf", ".gba")).read_bytes()
|
|
|
|
|
|
def u32(off):
|
|
return int.from_bytes(rom_bytes[off:off + 4], "little")
|
|
|
|
|
|
def points_at_text(row, stride, slots):
|
|
base = row[1] - B
|
|
for i in range(slots):
|
|
ptr = u32(base + i * stride)
|
|
if ptr == 0:
|
|
continue
|
|
if not (B <= ptr < B + len(rom_bytes)):
|
|
return False
|
|
if ptr in other_addrs:
|
|
return False
|
|
return True
|
|
|
|
|
|
def add_table(name, stride=4, inner=None, battle_table=False, inline=False):
|
|
if name in seen_tables or name in TABLE_DENY:
|
|
return
|
|
row = unique(name)
|
|
if not row or row[2] == 0:
|
|
skipped.append(name)
|
|
return
|
|
size = row[2]
|
|
if inner:
|
|
count = size // (4 * inner)
|
|
dims = (count, inner)
|
|
else:
|
|
count = size // stride
|
|
dims = None
|
|
if NAMES_MODE and not inline and not points_at_text(row, stride, count * (inner or 1)):
|
|
skipped.append(name)
|
|
return
|
|
seen_tables.add(name)
|
|
tables.append({"name": name, "addr": row[1] - B, "count": count, "stride": stride,
|
|
"dims": dims, "battle": battle_table, "inline": inline})
|
|
|
|
|
|
for path in sorted(TABLE_SOURCES):
|
|
rel = str(path.relative_to(pret))
|
|
if rel.startswith(("src/data/pokemon", "src/data/region_map", "src/help_system")):
|
|
continue
|
|
body = path.read_text(encoding="utf-8", errors="replace")
|
|
for m in POINTER_TABLE.finditer(body):
|
|
inner = None
|
|
if m.group(2):
|
|
inner_txt = m.group(2).strip("[] ")
|
|
if not inner_txt.isdigit():
|
|
continue
|
|
inner = int(inner_txt)
|
|
add_table(m.group(1), inner=inner, battle_table=rel == "src/battle_message.c")
|
|
|
|
STRUCT_TABLES = [
|
|
"sStartMenuActionTable", "sCursorOptions", "sMenuActions_TopMenu", "sMenuActions_ItemPc",
|
|
"sMenuActions_MailSubmenu", "sItemPcSubmenuOptions", "sShopMenuActions_BuySellQuit",
|
|
"sListMenuItems_KantoDexModeSelect", "sListMenuItems_NatDexModeSelect", "sLevelMenuItems",
|
|
"sListMenuItems", "sListMenuItems_NoTMCase", "sKeyboardSwapTexts", "sMenuActions",
|
|
"sItemMenuContextActions", "sListMenuItems_CardsOrNews", "sListMenuItems_WirelessOrFriend",
|
|
"sListMenuItems_ReceiveSendToss", "sListMenuItems_ReceiveToss", "sListMenuItems_ReceiveSend",
|
|
"sListMenuItems_Receive", "sContextMenuActions", "sMenuAction_SummaryTrade",
|
|
"sListMenuItems_PossibleGroupMembers", "sListMenuItems_UnionRoomGroups",
|
|
"sListMenuItems_InviteToActivity", "sListMenuItems_RegisterForTrade",
|
|
"sListMenuItems_TypeNames", "sListMenuItems_TradeBoard",
|
|
]
|
|
if NAMES_MODE:
|
|
STRUCT_TABLE = re.compile(r"const\s+struct\s+(?:MenuAction|ListMenuItem)\s+(\w+)\s*\[[^\]\n]*\]\s*=")
|
|
found = set()
|
|
for path in TABLE_SOURCES:
|
|
for m in STRUCT_TABLE.finditer(path.read_text(encoding="utf-8", errors="replace")):
|
|
if not m.group(1).startswith("MultichoiceList_"):
|
|
found.add(m.group(1))
|
|
STRUCT_TABLES = sorted(found)
|
|
for name in STRUCT_TABLES:
|
|
add_table(name, stride=8)
|
|
add_table("gTrainerClassNames", stride=13, inline=True)
|
|
add_table("gTypeNames", stride=7, inline=True)
|
|
|
|
defines = {}
|
|
for line in (pret / "include/constants/battle_string_ids.h").read_text().splitlines():
|
|
m = re.match(r"#define\s+(\w+)\s+(\w+)\s*$", line)
|
|
if m:
|
|
defines.setdefault(m.group(1), m.group(2))
|
|
|
|
|
|
def define_value(name):
|
|
v = defines[name]
|
|
return int(v) if v.isdigit() else define_value(v)
|
|
|
|
|
|
string_count = define_value("BATTLESTRINGS_COUNT")
|
|
table_start = define_value("BATTLESTRINGS_TABLE_START")
|
|
ids = {}
|
|
for line in (pret / "include/constants/battle_string_ids.h").read_text().splitlines():
|
|
m = re.match(r"#define\s+(STRINGID_\w+)\s+(\d+)\s*$", line)
|
|
if m and int(m.group(2)) < string_count:
|
|
ids.setdefault(int(m.group(2)), m.group(1))
|
|
for t in tables:
|
|
if t["name"] == "gBattleStringsTable":
|
|
t["ids"] = table_start
|
|
|
|
if NAMES_MODE:
|
|
lines = ["-- Generated by tools/gen_rom_text_tables.py --game " + OPTS["game"] + " from pret ELF symbol names.",
|
|
"return {"]
|
|
else:
|
|
lines = ["-- Generated by tools/gen_rom_text_tables.py from matching pret ELF symbols.",
|
|
"return {"]
|
|
|
|
|
|
def block(title, table):
|
|
lines.append(f" {title} = {{")
|
|
for name in sorted(table):
|
|
if NAMES_MODE:
|
|
lines.append(f' "{name}",')
|
|
else:
|
|
lines.append(f" {name} = 0x{table[name]:X},")
|
|
lines.append(" },")
|
|
|
|
|
|
block("NAMED_TEXTS", named)
|
|
block("NAMED_BATTLE_TEXTS", battle)
|
|
lines.append(" TEXT_TABLES = {")
|
|
for t in sorted(tables, key=lambda r: r["name"]):
|
|
if NAMES_MODE:
|
|
parts = [f'name = "{t["name"]}"', f'stride = {t["stride"]}']
|
|
else:
|
|
parts = [f'name = "{t["name"]}"', f'addr = 0x{t["addr"]:X}', f'count = {t["count"]}',
|
|
f'stride = {t["stride"]}']
|
|
if t["dims"]:
|
|
parts.append(f'inner = {t["dims"][1]}')
|
|
if t["battle"]:
|
|
parts.append("battle = true")
|
|
if t["inline"]:
|
|
parts.append("inline = true")
|
|
if t.get("ids"):
|
|
parts.append(f'ids = {t["ids"]}')
|
|
lines.append(" { " + ", ".join(parts) + " },")
|
|
lines.append(" },")
|
|
lines.append(" BATTLE_STRING_IDS = {")
|
|
for i in sorted(ids):
|
|
lines.append(f' [{i}] = "{ids[i]}",')
|
|
lines.append(" },")
|
|
lines.append("}")
|
|
out = Path(OPTS["out"]) if OPTS["out"] else ROOT / GAME["out"]
|
|
out.write_text("\n".join(lines) + "\n")
|
|
print(f"Wrote {len(named)} named, {len(battle)} battle, {len(tables)} tables, "
|
|
f"{len(ids)} string ids to {out}; skipped {len(skipped)}")
|
|
if OPTS["verbose"]:
|
|
print("skipped:", " ".join(sorted(set(skipped))))
|