re: the base-solver -- 277 name-block loaders indexed, and the analog block is SOLVED

New reusable tool, tools/re-capture/name_block_bases.py -> docs/re/data/name-block-bases.txt
(2880 lines, ~65 s, byte-identical across two runs).

A loader that reads a table by field name keeps one base pointer and emits
"addi rX, rBASE, -N" per name, so no static xref sees the strings.  Solve the base
from the DISPLACEMENT SET alone: every (string address, displacement) pair implies
a candidate base, and the true base collects a vote from every name it explains,
so it wins outright.  My first cut took candidates from ONE displacement and
scored the unit loader at 52/226 against the right answer's 217/226 -- vote over
the whole set, not a probe.

Control passes with no prior knowledge: the tool recovers sub_82341A20 -> r30 =
0x82088F94 at 217/226, and independently recovers sub_8230D1F8 (129/132),
sub_822F9498 (90/91) and sub_822AE628 (81/108).  277 name-block-reading functions
image-wide, with the schema each names.

The analog block is SOLVED: sub_821A6CF0, r29 = 0x820A1630, 22/24.  In code order
it names ControlTweakName, YawMagForNormal, the 12 Tweak fields, the 8
AnalogRevice_* curves and GP_MAIN_GAME -- the whole schema in the object's own
order plus its pak.  r29 is built at 0x821A6D34 as addi r29, r11, 5680 =
0x820A0000 + 5680, matching the solved base exactly.  It is the same function that
reads PlayerParams.

Two of my own verdicts withdrawn: "referenced by nothing" and the softened "not
found by these routes".  The measurements behind them were right; the conclusions
were wrong.  The base was solvable from the data the whole time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
This commit is contained in:
Claude (auto)
2026-08-27 21:22:50 +00:00
parent 38e0a63946
commit ae37e15d7d
5 changed files with 3067 additions and 21 deletions

View File

@@ -5039,3 +5039,46 @@ in `player-tuning-tables.md`; runtime watch still the cheap way in.
🟡 Not settled: which table governs the measured player speeds (unchanged); a
base-tracking sweep strong enough to follow register-passed bases was not built.
---
## ✅✅ 2026-08-27 — the base-solver: 277 name-block loaders, and the analog block is SOLVED
Item (b). New regenerator `tools/re-capture/name_block_bases.py`
`docs/re/data/name-block-bases.txt` (2 880 lines, ~65 s, byte-identical across
two runs). **This is a reusable tool, not a one-off.**
🔑 **The method.** A loader that reads a table by field name keeps one base
pointer and emits `addi rX, rBASE, -N` per name, so no static xref sees the
strings. Solve the base from the **displacement set alone**: every (string
address, displacement) pair implies a candidate base, and the true base collects
a vote from **every name it explains**, so it wins outright.
⚠️ My first cut took candidates from ONE displacement and scored the unit loader
at **52/226** against the right answer's **217/226** — a plausible wrong base.
**Vote over the whole set, not a probe.**
**Control passes with no prior knowledge:** the tool recovers
`sub_82341A20 → r30 = 0x82088F94`, **217/226**. It also independently recovers
`sub_8230D1F8` (129/132), `sub_822F9498` (90/91) and `sub_822AE628` (81/108),
and reports **277** name-block-reading functions image-wide with the schema each
names — an index the corpus did not have.
✅✅ **The analog block is SOLVED: `sub_821A6CF0`, `r29 = 0x820A1630`, 22/24.**
In code order it names `ControlTweakName`, `YawMagForNormal`, the 12 `Tweak`
fields, the 8 `AnalogRevice_*` curves and `GP_MAIN_GAME` — the whole schema in
the object's own order, plus its pak. `r29` is built at `0x821A6D34` as
`addi r29, r11, 5680` = `0x820A0000 + 5680`, matching the solved base exactly.
🔑 **It is the same function that reads `PlayerParams`** (it references that
literal and calls `sub_822F9498`): one function loads the player parameter object
*and* the control-tweak/analog table.
🔴 **Two of my own verdicts withdrawn.** "Referenced by nothing" (🔴) and the
softened "not found by these routes" (🟡) were both wrong; the measurements
behind them — 0/28 xrefs, 0 hits from naive base tracking — were right. **The
base was solvable from the data the whole time.** ⚠️ `r1` groups are the tool's
noise floor (stack frames) and are excluded.
🟡 Not settled: how the 11 curve samples are *applied* (the loader names the
records; the interpolation is downstream); the remaining 255 of the 277 solved
functions are listed but unexamined; what selects `Booster` (unchanged).

View File

@@ -103,7 +103,7 @@ files, which is how the same ground got covered twice.
| [`structures/stage-settings-table.md`](structures/stage-settings-table.md) | The per-stage settings object — Camera rigs, Player limits, Difficulty_* damage record | ✅ CONFIRMED, 24 settings objects vs 29 resource objects both using Phase_1/2/3; Camera is 13/14 fields constant; GravityFactor non-zero in 4 stages; IsBoss16Enable identifies one object; the Phase block is 94 fields of which 68 never vary between phases; all 24 objects named StageParameter_S<NN> |
| [`structures/isl-mission-timer.md`](structures/isl-mission-timer.md) | The mission timer — two sequential countdowns, not a limit and a warning | ✅ CONFIRMED — read from `sub_822639B8`; the threshold reading is |
| [`structures/archive-naming.md`](structures/archive-naming.md) | Which archives the disc can name — 100% for menu paks, **0.0%** for the six 2D paks and `GP_READY_ROOM` | ✅ CONFIRMED — 6027 names × 16 prefixes, bimodal; unnamed entries split by content — three UI paks have ZERO unnamed IDXD (artwork only), DefTables is the only real data gap (1295 LOD/motion tables); **route 2 = the `Enumerate` declaration tables** closes DefTables to 1413/1425 IDXD (99.2 %), 12 left; XPR2 manifests refuted as a naming source |
| [`structures/player-tuning-tables.md`](structures/player-tuning-tables.md) | **`PlayerParams`** — the player parameter table: `Booster` flight model, special attacks, 8 analog curves, the S16 boss | ✅✅ `sub_822F9498` is `PlayerParams`'s loader, NOT the unit loader `sub_82341A20` (90/90 strings); all 5 player craft fly identically, `Booster` differs on 10 fields; 🟡 nothing selects it, and the analog block has no xref — but 0-xref does not mean no reader |
| [`structures/player-tuning-tables.md`](structures/player-tuning-tables.md) | **`PlayerParams`** — the player parameter table: `Booster` flight model, special attacks, 8 analog curves, the S16 boss | ✅✅ `sub_822F9498` is `PlayerParams`'s loader, not the unit loader; **the analog block's reader SOLVED — `sub_821A6CF0`, base `0x820A1630`**, via the new base-solver (277 name-block loaders image-wide, control 217/226 on `sub_82341A20`); 🟡 nothing selects `Booster` |
| [`structures/hud-config.md`](structures/hud-config.md) | The in-game HUD configuration — 16 records, 419 asset paths, and a per-stage `ResourceTable` | ✅ CONFIRMED — 0 of 419 config paths resolve as pak entries |
| [`structures/mission-script-manifest.md`](structures/mission-script-manifest.md) | `Stage\script.tbl`'s 11 non-`MISSION` fields, and the mission dialogue table they name | ✅ CONFIRMED — 1 of 7 values resolves (`message\MissionDialogMessage.tbl`, 200 records), controls in the same sweep |
| [`structures/stage-numbering-and-player-craft.md`](structures/stage-numbering-and-player-craft.md) | The disc's stage numbering (S01S16, S18S29) and the player's craft per stage | ✅✅ CONFIRMED — two independent files close on 6 + 15 + 1 + 6 = 28 |

File diff suppressed because it is too large Load Diff

View File

@@ -135,35 +135,50 @@ unit `Maneuver` 100 / 350 / 1 200 at a flat ~1.25 and misses `Booster`'s
runtime one:** watch which of the two constant sets reaches the live flight
struct, and re-measure with the afterburner held.
## 🟡 The analog-curve block is referenced by nothing *that I can see* — verdict softened
## ✅✅ SOLVED — `sub_821A6CF0` reads the analog block, found by solving the base
The `AnalogRevice_*` / `Tweak` schema is a second contiguous block at
**`0x820A119C0x820A1378`**, in the object's own order:
Two iterations called this block unreachable. It is not: it is read through a
**base register**, and the base can be *solved from the displacements alone*.
🔑 **The method** (`tools/re-capture/name_block_bases.py`
[`../data/name-block-bases.txt`](../data/name-block-bases.txt)): for each group
of `addi rX, rBASE, -N` sharing a source register, every (string address,
displacement) pair implies one candidate base; the true base collects a vote from
**every name it explains**, so it wins outright. ⚠️ Taking candidates from one
displacement instead misses it — that first cut scored the unit loader at 52/226
against the right answer's 217/226.
✅ **Control, with no prior knowledge: the tool recovers `r30 = 0x82088F94` for
the unit loader `sub_82341A20`, resolving 217 of 226 displacements.** It also
independently recovers `sub_8230D1F8` (129/132), `sub_822F9498` (90/91) and
`sub_822AE628` (81/108) — and finds **277** such functions image-wide.
**The answer: `sub_821A6CF0`, `r29 = 0x820A1630`, 22 of 24 displacements.** In
code order it names
```
ControlTweakName
ControlTweakName YawMagForNormal
mov_stick_play mov_trigger_play eye_stick_play receipt_after_b
receipt_reverse_s receipt_tgt_near receipt_tgt_atk receipt_side_s
minimum_side_s receipt_match_spd order_cancel_time YawMagForNormal
AnalogRevice_adv_roll … AnalogRevice_throttle (8 curve names)
minimum_side_s receipt_match_spd order_cancel_time
AnalogRevice_adv_roll … AnalogRevice_throttle (8 curve names)
GP_MAIN_GAME
```
**0 of its 28 strings has a code xref**, no instruction names a `0x820A1xxx`
operand, and a base-plus-displacement sweep (2 283 functions that form the
`0x820A` high half) reaches it **0** times.
— the whole `Tweak` + `AnalogRevice_*` schema, in the object's own order, plus
the **pak** it comes from. `r29` is built at `0x821A6D34` as `addi r29, r11, 5680`
= `0x820A0000 + 5680` = `0x820A1630`, confirming the solved base exactly.
🟡 **But "no xref" does not mean "no reader", and the corpus proves it.** The
*unit*-definition name block at `0x82085B38` — which includes `Maneuver` — also
has **0 xrefs**, and it is read by `sub_82341A20`, which builds every key as
`addi rX, r30, -N` from a base **passed in a register** (`Maneuver` sits at
`0x82088F94 13404`). No static xref can see that, and my base-tracking sweep
cannot either: run against that known-read block as a **control**, it recovers
only **5** references and misses `sub_82341A20` itself.
🔑 **And it is the same function that reads `PlayerParams`**`sub_821A6CF0`
references that literal and calls `sub_822F9498`. So one function loads the
player's parameter object *and* the control-tweak/analog table. 🟡 It reads the
curves as `AnalogRevice_*` record names; how the 11 samples are then *applied*
is still unread.
⇒ The measurement stands; last iteration's 🔴 "the reader is unreachable" was too
strong. The honest verdict is **🟡 not found by xref or by naive base tracking,
both of which have a demonstrated blind spot** for register-passed bases. A
runtime watch is still the cheap way in.
> Withdrawn: the earlier 🔴 "referenced by nothing" and the softened 🟡
> "not found by these routes". Both measurements were right — 0/28 xrefs, 0 hits
> from naive base tracking — and both conclusions were wrong. **The base was
> solvable from the data the whole time.**
⚠️ **The twin-string-block trap, in the flesh.** `mov_stick_play` and
`eye_stick_play` each exist **twice**; only the *second* copy (`0x820AA630`,

View File

@@ -0,0 +1,108 @@
#!/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))
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()