Files
pokemon_pinball_wiki/scripts/extract_native_sound_registrations.py
2026-07-07 00:25:38 -05:00

144 lines
4.6 KiB
Python

#!/usr/bin/env python3
"""Extract native GodotSpikeBridge sound registrations from the game binary.
This is a static AArch64 pattern extractor for the registration table
identified in Ghidra as FUN_005d6b90 -> FUN_006ec060(name, id, -1).
It does not execute the target binary.
"""
from __future__ import annotations
import argparse
import csv
import struct
from pathlib import Path
GHIDRA_BASE_DELTA = 0x100000
GHIDRA_REGISTER_SOUND = 0x006EC060
TEXT_START = 0x00460000
TEXT_END = 0x0258A7B0
def u32(data: bytes, offset: int) -> int:
return struct.unpack_from("<I", data, offset)[0]
def decode_bl_target(pc: int, insn: int) -> int | None:
if insn & 0xFC000000 != 0x94000000:
return None
imm = insn & 0x03FFFFFF
if imm & 0x02000000:
imm -= 0x04000000
return pc + imm * 4
def decode_movz_w2(insn: int) -> int | None:
if insn & 0x7F80001F != 0x52800002:
return None
shift = (insn >> 21) & 0x3
if shift != 0:
return None
return (insn >> 5) & 0xFFFF
def decode_adrp(pc: int, insn: int) -> tuple[int, int] | None:
if insn & 0x9F000000 != 0x90000000:
return None
reg = insn & 0x1F
immlo = (insn >> 29) & 0x3
immhi = (insn >> 5) & 0x7FFFF
imm = (immhi << 2) | immlo
if imm & (1 << 20):
imm -= 1 << 21
return reg, (pc & ~0xFFF) + imm * 0x1000
def decode_add_imm(insn: int) -> tuple[int, int, int] | None:
if insn & 0xFFC00000 != 0x91000000:
return None
rd = insn & 0x1F
rn = (insn >> 5) & 0x1F
shift = (insn >> 22) & 0x3
imm = (insn >> 10) & 0xFFF
if shift == 1:
imm <<= 12
elif shift != 0:
return None
return rd, rn, imm
def read_c_string(data: bytes, offset: int) -> str:
end = data.find(b"\0", offset)
if end < 0:
end = min(len(data), offset + 256)
return data[offset:end].decode("ascii", errors="replace")
def extract(data: bytes, target_file_addr: int) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
start = min(TEXT_START, len(data))
end = min(TEXT_END, len(data))
for pc in range(start, end, 4):
insn = u32(data, pc)
if decode_bl_target(pc, insn) != target_file_addr:
continue
sound_id = None
string_addr = None
for cursor in range(pc - 4, max(TEXT_START, pc - 0x100), -4):
prior = u32(data, cursor)
if sound_id is None:
sound_id = decode_movz_w2(prior)
add_info = decode_add_imm(prior)
if add_info is not None and cursor >= TEXT_START + 4:
rd, rn, add_imm = add_info
adrp_info = decode_adrp(cursor - 4, u32(data, cursor - 4))
if adrp_info is not None:
adrp_reg, adrp_addr = adrp_info
if rd == rn and rn == adrp_reg:
string_addr = adrp_addr + add_imm
if sound_id is not None and string_addr is not None:
break
rows.append(
{
"name": read_c_string(data, string_addr) if string_addr is not None else "",
"native_sound_id": sound_id if sound_id is not None else "",
"register_call_ghidra": f"0x{pc + GHIDRA_BASE_DELTA:08x}",
"register_call_file_offset": f"0x{pc:08x}",
"string_ghidra": f"0x{string_addr + GHIDRA_BASE_DELTA:08x}" if string_addr is not None else "",
"string_file_offset": f"0x{string_addr:08x}" if string_addr is not None else "",
"register_function_ghidra": f"0x{GHIDRA_REGISTER_SOUND:08x}",
}
)
return rows
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--game-binary", type=Path, default=Path("games/pokemon_pro/game"))
parser.add_argument("--out", type=Path, default=Path("analysis/radium-image/native_sound_id_map.tsv"))
args = parser.parse_args()
data = args.game_binary.read_bytes()
rows = extract(data, GHIDRA_REGISTER_SOUND - GHIDRA_BASE_DELTA)
args.out.parent.mkdir(parents=True, exist_ok=True)
fields = [
"name",
"native_sound_id",
"register_call_ghidra",
"register_call_file_offset",
"string_ghidra",
"string_file_offset",
"register_function_ghidra",
]
with args.out.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fields, delimiter="\t")
writer.writeheader()
writer.writerows(rows)
print(f"wrote {args.out} rows={len(rows)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())