This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/tools/re-capture/name_block_bases.py
Claude (auto) 17b4a791eb re: mining the base-solver index -- a false-positive mode named, and the AI-table reader found
277 rows over 190 distinct functions (a function can read several blocks).
name_block_bases.py extended with a confidence split.

The tool's false-positive mode, measured and named: 107 of 277 rows solve to a
base on a 64K boundary -- a bare "addis rX, r0, 0xHHHH" with no addi, so any
scatter of displacements votes for it.  82 are 0x820B0000: about 60
near-identical functions in 0x8281xxxx-0x8284xxxx all "naming" the same rou_e0NN
list.  The 170 rows with a non-zero low half are the trustworthy set.  A round
base is not automatically wrong -- sub_822215D0 sits on 0x820A0000 and resolves
205/206 -- so read the ratio, not the base.

The index re-derives every loader we already knew (unit 217, stage settings 129,
PlayerParams 90, hangar 81, squadron orders, missile guidance, shell movement,
substructures, six camera/fog readers) -- that is the control.

The find: sub_8233C368 reads the AI behaviour table.  r28, base 0x8208583C, 20
names -- Enumerate_AIs, FiringLength, GuardLength, AutoGuardLength, CounterLength,
MusterLength.  stage-mission-tables.md owns those field names on the data side,
but Enumerate_AIs appears in no document and no reader was known; the corpus
carries the AI tail of Maneuver as NEEDS-HUMAN/runtime.  It is statically
reachable after all.  The same base also serves sub_82338EE0 (97 names, Weapon
TargetType SpecialWeaponType ReticleType IsCharging ...) -- the weapon datasheet
loader, also not previously named.

Five unowned blocks surfaced and NOT opened: PGHUD_*/PGREMAIN_NUM HUD part names
(205/206), STAGE_RESULT/stage_num_shoot_down_aircrafts/EX_OVERVIEW,
g_mWorldViewProjection/NormalMap/GlossinessMap engine material slots,
Boss16Collision* (cross-links the S16 Guardian object), and roh_n001_menu1_cam_pos
menu camera tags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-27 21:41:03 +00:00

124 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""Solve the BASE of every register-relative name block, and print the schema it names.
A loader that reads a table by field name usually does not take each string's
address directly -- it keeps one base pointer and emits `addi rX, rBASE, -N` per
name. No static xref sees that, so those blocks look unreferenced.
This solves the base from the DISPLACEMENT SET alone: for a group of `addi`s
sharing a source register, try every candidate base implied by (string address -
displacement) and keep the one that lands the most displacements on a string
start. Control: it must recover `r30 = 0x82088F94` for the unit loader
`sub_82341A20` with no prior knowledge.
Regenerates docs/re/data/name-block-bases.txt.
"""
import sys, collections, bisect
DB = '/work/xenia-rs/sylpheed.db'
MIN_GROUP = 8 # displacements needed before a group is worth solving
MIN_RESOLVED = 12 # report a function only if the base explains this many
LOW, HIGH = 0x82000000, 0x82400000
def main():
import duckdb
con = duckdb.connect(DB, read_only=True)
S = set(a for (a,) in con.execute("SELECT address FROM strings").fetchall())
txt = dict(con.execute("SELECT address, content FROM strings").fetchall())
funcs = con.execute(
"SELECT address,end_address,name FROM functions ORDER BY address").fetchall()
starts = [f[0] for f in funcs]
ins = con.execute(
"SELECT address,operands FROM instructions WHERE mnemonic='addi' ORDER BY address"
).fetchall()
print("# Register-relative name blocks: the base solved from the displacements")
print("# Regenerate: python3 tools/re-capture/name_block_bases.py")
print("# See docs/re/structures/player-tuning-tables.md")
print("\n strings in the image: %d addi instructions: %d" % (len(S), len(ins)))
byf = collections.defaultdict(lambda: collections.defaultdict(list))
order = collections.defaultdict(lambda: collections.defaultdict(list))
for a, o in ins:
i = bisect.bisect_right(starts, a) - 1
if i < 0 or funcs[i][1] <= a:
continue
p = [x.strip() for x in o.split(',')]
if len(p) != 3 or p[0] == p[1]:
continue
try:
d = int(p[2], 0)
except ValueError:
continue
byf[funcs[i][2]][p[1]].append(d)
order[funcs[i][2]][p[1]].append((a, d))
rows = []
for name in sorted(byf):
for reg in sorted(byf[name]):
D = sorted(set(byf[name][reg]))
if len(D) < MIN_GROUP or reg == 'r1': # r1 is the stack pointer
continue
# Vote: every (string, displacement) pair implies one candidate base.
# The true base collects a vote from each name it explains, so it wins
# outright -- taking candidates from ONE displacement misses it.
cand = collections.Counter()
for A in S:
for d in D:
B = A - d
if LOW <= B <= HIGH:
cand[B] += 1
best = None
# sorted, not most_common(): a Counter's tie order varies per run
top = sorted(cand.items(), key=lambda kv: (-kv[1], kv[0]))[:40]
for B, _v in top:
tot = sum(1 for d in D if (B + d) in S)
if best is None or tot > best[1] or (tot == best[1] and B < best[0]):
best = (B, tot)
if best and best[1] >= MIN_RESOLVED:
rows.append((name, reg, best[0], best[1], len(D)))
rows.sort(key=lambda r: (-r[3], r[0]))
print("\n## %d functions read a name block through a base register" % len(rows))
print(" %-18s %-5s %-12s %s" % ('function', 'reg', 'base', 'resolved / displacements'))
for name, reg, B, tot, n in rows:
print(" %-18s %-5s 0x%08X %d / %d" % (name, reg, B, tot, n))
# Confidence. A base whose low half is 0x0000 is a bare `addis` with no
# `addi` of its own -- any scatter of displacements votes for it, so those
# rows are the tool's false-positive mode and must be read with the
# resolution ratio, not on their own.
round_rows = [r for r in rows if (r[2] & 0xFFFF) == 0]
solved_rows = [r for r in rows if (r[2] & 0xFFFF) != 0]
print("\n## Confidence split")
print(" bases with a non-zero low half (a real `addis`+`addi` pair): %d" % len(solved_rows))
print(" bases on a 64K boundary (LOW CONFIDENCE, see below) : %d" % len(round_rows))
rb = collections.Counter(r[2] for r in round_rows)
for B, c in sorted(rb.items(), key=lambda kv: (-kv[1], kv[0])):
print(" 0x%08X x%d rows" % (B, c))
print(" ⚠ the 0x820B0000 cluster is ~60 near-identical functions in"
" 0x8281xxxx-0x8284xxxx that all name the same `rou_e0NN` list.")
print("\n## The schema each one names, in code order")
for name, reg, B, tot, n in rows:
seen, names = set(), []
for a, d in order[name][reg]:
t = B + d
if t in txt and t not in seen:
seen.add(t)
names.append(txt[t])
if not names:
continue
print("\n %s (%s, base 0x%08X) names %d:" % (name, reg, B, len(names)))
line = " "
for nm in names:
if len(line) + len(nm) > 96:
print(line)
line = " "
line += nm + " "
if line.strip():
print(line)
if __name__ == "__main__":
main()