Files
BFM-decomp/tools/psyq_identify.py
T
Drew T 1b9397c462 refactor(phase-9): T8b — remove transitional defaults across link pipeline (no-EXE-default end state)
Every binary-specific value is now a REQUIRED parameter — the phase's #1-risk
mitigation (no EXE default an overlay could silently inherit):
- psyq_link.py: removed module EXE/VRAM_BASE; recover_sym_addrs/unique_byte_vram
  vram_base now positional-required; link_object vram_base keyword-required (*,...);
  CLI --vram-base/--exe required
- psyq_identify.py: removed EXE/VRAM_BASE globals; --vram-base/--exe required
- psyq_link_region.py + psyq_link_lib.py: dropped 'VRAM_BASE' import + EXE; defaults
  removed (placement/classify/build_region required); CLI required
- psyq_integrate.py: dropped VRAM_BASE import + EXE; integrate() vram_base/exe_path/
  symbols_path keyword-required; CLI --vram-base/--exe/--symbols required
- PROOFS: psyq_link bare -> 'required: --vram-base, --exe' (loud); build with AND
  without SDK -> 143dbb89; wrong base -> cae22f7e; curated dirs regenerate identical
2026-06-15 21:00:36 -06:00

102 lines
4.3 KiB
Python

#!/usr/bin/env python3
"""Locate where PsyQ library objects are linked in the target EXE.
For each ELF .o (converted from a PsyQ .LIB member), extract its `.text` and the
relocation offsets, build a relocation-masked word pattern (relocated immediate
fields zeroed), and scan the EXE text for the single position where every
NON-relocated word matches. That position is the object's link address in the EXE
(or "absent" if the EXE doesn't link it). This is the placement map the library
linker step consumes.
Usage: psyq_identify.py <elf_dir> [text_lo_vram text_hi_vram] [--vram-base HEX] [--exe PATH]
(window defaults to the BFM .text 0x80010000..0x800629DC; --vram-base/--exe default to
the EXE's values, becoming required in Phase-9 T8 once every caller passes them)
"""
import struct, subprocess, re, sys, glob, os, argparse
# Phase 9: --vram-base (fileoff->vram delta) and --exe are REQUIRED (no EXE default an overlay
# could silently inherit). The optional [lo hi] window only narrows the placement scan.
_ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
_ap.add_argument("elf_dir", nargs="?", default=".run/obj40/libcd")
_ap.add_argument("window", nargs="*", help="optional scan-narrowing window: text_lo_vram text_hi_vram")
_ap.add_argument("--vram-base", required=True,
help="fileoff->vram delta of the target binary (e.g. the EXE's 0x8000F800)")
_ap.add_argument("--exe", required=True, help="target binary path")
_a = _ap.parse_args()
ELF_DIR = _a.elf_dir
EXE = _a.exe
VRAM_BASE = int(_a.vram_base, 0)
TLO = int(_a.window[0], 0) if len(_a.window) > 0 else 0x80010000
THI = int(_a.window[1], 0) if len(_a.window) > 1 else 0x800629DC
b = open(EXE, "rb").read()
text = b[TLO - VRAM_BASE: THI - VRAM_BASE]
twords = [struct.unpack_from("<I", text, i)[0] for i in range(0, len(text), 4)]
def obj_text_pattern(o):
"""Return (words, mask) for the object's .text; mask[i]=0 on relocated/jump words.
A data-only object (no `.text` section — e.g. libgs GLOBAL.o, which defines only
globals) makes `objdump -j .text` exit non-zero; treat that as an empty .text so the
caller's `no-.text` path handles it instead of crashing."""
p = subprocess.run(["mipsel-linux-gnu-objdump", "-dr", "-j", ".text", o],
capture_output=True, text=True)
if p.returncode != 0:
return [], []
d = p.stdout
words, mask = [], []
pending_reloc = False
for line in d.splitlines():
mi = re.match(r"\s+([0-9a-f]+):\s+([0-9a-f]{8})\s", line)
if mi:
w = int(mi.group(2), 16)
words.append(w)
# mask jal/j (opcode 2/3) always (R_MIPS_26 target is link-resolved)
mask.append(0 if (w >> 26) in (2, 3) else 0xFFFFFFFF)
elif "R_MIPS" in line and words:
# relocation annotation follows its instruction line -> mask low 16 (hi/lo/pc16)
if "_26" in line:
mask[-1] = 0
else:
mask[-1] = 0xFFFF0000
return words, mask
def find(words, mask):
n = len(words)
if n < 2:
return None # too short to anchor uniquely
hits = []
for s in range(0, len(twords) - n + 1):
ok = True
for i in range(n):
if (twords[s + i] & mask[i]) != (words[i] & mask[i]):
ok = False; break
if ok:
hits.append(s)
if len(hits) > 1:
break
if len(hits) == 1:
return TLO + hits[0] * 4
return ("ambiguous" if len(hits) > 1 else None)
results = []
for o in sorted(glob.glob(os.path.join(ELF_DIR, "*.o"))):
words, mask = obj_text_pattern(o)
if not words:
results.append((os.path.basename(o), "no-.text", len(words))); continue
r = find(words, mask)
results.append((os.path.basename(o), r, len(words)))
found = [(n, a, l) for n, a, l in results if isinstance(a, int)]
found.sort(key=lambda x: x[1])
print(f"{ELF_DIR}: {len(found)}/{len(results)} objects located in EXE text "
f"[0x{TLO:X}..0x{THI:X}]")
for n, a, l in found:
print(f" 0x{a:08X} {n:18s} ({l} ins)")
absent = [n for n, a, l in results if a is None]
amb = [n for n, a, l in results if a == "ambiguous"]
if absent:
print(f" not linked by EXE ({len(absent)}): {', '.join(absent[:12])}{' …' if len(absent)>12 else ''}")
if amb:
print(f" ambiguous ({len(amb)}): {', '.join(amb)}")