Files
Sylpheed/docs/re/structures/player-tuning-tables.md
Sylpheed RE agent 80235857af re: the roster's two vocabularies, and a prefix match I mistook for a hit
Correction to the previous commit's own result. The raw byte sweep reported
rot_n001 "26 times in 7 archives" - a SUBSTRING count. The exact field value
occurs zero times, in every IDXD record and every non-IDXD payload. What the
disc carries is rot_n001_break x14, LOD_Frame_rot_n001_break x7,
GameModel_rot_n001_break x5. rot_n001 exact is not on this disc. A prefix
match is not an exact match, and a byte sweep is the tool that breaks it.

rou_e202 does exist, in the other vocabulary: a Model field of a Generic
record, six times, one per GP_MAIN_GAME_<lang> pack, with no GameResourceID -
which is why the GameResourceID census called it absent.

Set arithmetic over every IDXD record: roster 40, Generic.Model 46,
GameResourceID 480; roster n Model 34, roster n GID 38, roster n both 33.
38 + 34 - 33 = 39, so 39 of 40 are one or the other and rot_n001 is the sole
exception. The six that are GID but not Model: mob_n040..043 are the four
AsteroidGroup_00N.AsteroidModelName values (asteroid models, not units) and
rou_e004 is a Level_0.Model.

What selects the 40 is still open, but two answers are dead: not the
Generic.Model set (34 of 46), not the GameResourceID set (38 of 480). The 12
Generic.Model values outside the roster include rou_f002 and rou_f004 - the
player's own craft for S7-S16/S24-27,29 and for S28 - while rou_f001 is kept,
so "the playable models" is dead too.

All fifteen artefacts byte-identical.
2026-08-28 01:15:38 +00:00

547 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ✅ The player-tuning tables — analog response curves, booster/flight limits, special attacks
- **Regenerate:** `python3 tools/re-capture/main_game_unnamed.py` → [`../data/main-game-unnamed.txt`](../data/main-game-unnamed.txt)
- **Where:** `GP_MAIN_GAME_{D,E,F,I,J,S}.pak`, as **unnamed** IDXD objects — identical hash sets in all six language copies.
- **Related:** [[flight-speed-law]], [[flight-controls-runtime]], [[unit-datasheet-static]], [[archive-naming]].
Found while characterising the 337 unnamed IDXD objects in each main-game pak
(see `archive-naming.md`). **333 of the 337 belong to families the corpus already
documents.** These are the four that do not.
## ✅ The analog stick response curves — object `955ca077`
Eight records, one per input axis, each **11 positional values plus a named
`Count = 11`**. ⚠️ `Count` naming an indexed group is not safe in general — here
it was tested and **holds 8/8**.
| record | shape | curve |
|---|---|---|
| `AnalogRevice_yaw` | **identity** | `0.0 0.1 … 1.0` |
| `AnalogRevice_roll` | **identity** | `0.0 0.1 … 1.0` |
| `AnalogRevice_throttle` | **identity** | `0.0 0.1 … 1.0` |
| `AnalogRevice_pitch` | strongly eased | `0.000 0.003 0.010 0.020 0.040 0.065 0.100 0.155 0.250 0.500 1.000` |
| `AnalogRevice_eye_pitch` | eased | `0.00 0.01 0.03 0.06 0.10 0.18 0.28 0.42 0.60 0.80 1.00` |
| `AnalogRevice_eye_yaw` | eased, flat dead lead-in | `0.00 0.00 0.00 0.00 0.03 0.10 0.18 0.29 0.45 0.68 1.00` |
| `AnalogRevice_adv_roll` | S-curve | `0.000 0.005 0.032 0.080 0.138 0.231 0.387 0.584 0.792 0.941 1.000` |
| `AnalogRevice_adv_yaw` | **not monotone** | `0.000 0.016 0.052 0.143 0.482 0.940 1.000 0.916 0.611 0.382 0.208` |
**Three of the eight are the identity ramp** — yaw, roll and throttle are
unshaped, and the shaping the game does apply is all on **pitch and the camera
("eye") axes**. 🟡 `adv_yaw` **rises to 1.0 at sample 6 then falls back to
0.208**, so whatever it indexes, it is *not* a stick-deflection curve like the
other seven; it has the shape of an envelope over something else. Untested.
The same object carries a `Tweak` record with what read as **deadzones and
input-timing constants** — `mov_stick_play = 5000`, `eye_stick_play = 6000`
(raw stick units), `order_cancel_time = 120`, `YawMagForNormal = 2.0`, and six
`receipt_*` / `minimum_side_s` values. 🟡 The units are inferred from magnitude,
not read from the consumer.
## 🔴 ~~The player craft's flight envelope~~ — object `890e1be4`, CORRECTED
> **My label from the first pass was wrong twice over.** Kept, with the
> measurements that refuted it, because both errors are instructive.
**Error 1 — `Booster` is not a new schema.** Its 50 field names are a **strict
subset of the unit `Maneuver` record: 50/50 shared, zero `Booster`-only**. (The
first check compared against `Generic` and scored 0/50, which looked like a
brand-new structure — *the wrong record of a multi-record object is not a
control*.) `CruisingVelocity` appears in exactly **115** records disc-wide: 114
`Maneuver` + this one `Booster`.
**Error 2 — it is not the profile the player actually flies.**
[[flight-speed-law]] measured the Delta Saber at **~125 / ~420 / ~1 530**
(min / cruise / max). Against the two candidate tables:
| | min | cruise | max | ratios measured ÷ table |
|---|---:|---:|---:|---|
| unit `Maneuver` (5 units carry it) | 100 | 350 | 1 200 | **1.25 / 1.20 / 1.28** — consistent |
| `Booster` | 100 | 612 | 2 100 | 1.25 / **0.69** / **0.73** — not |
**The flight matches the unit `Maneuver` record**, at one consistent scale.
`Booster` is a **second, faster profile** — and the way it differs is the
finding: **39 of its 50 values are identical to the player unit's `Maneuver`**,
and of the 11 that move, the three velocities are scaled by **exactly ×1.75**
(350 → 612, i.e. 612.5 rounded; 1 200 → 2 100; `SideThrustVelocity_Max`
500 → 875) and two accelerations by **×1.5** (`Acceleration` 600 → 900,
`SideThrustAcceleration` 1 000 → 1 500). The rest: `Deceleration` 500 → 600,
`PowerCutDeceleration` 10 → 15, `PowerCutConsumeShield` 20 → 75,
`AB_ConsumeShield_Begin` 50 → **20**, `ArterBurner_Vc` 2.0 → **1.5**.
🟡 What that profile is *for* — an upgrade, a difficulty tier, a boost mode — is
not settled. Nothing selects it on the disc that I could find.
The object's other records stand as described: `TacticalManeuver`
(`Turn180RequiredTime 0.5`, `RollL/RRequiredTime 1.0`, `RollL/RSlideLength 350`,
`RollToHorizonTime 2.0`), `SpecialAttack` + `SpecialAttackGauge_1/2/3`
(`ChargeMaximum` 33 / 66 / 100), `SpecialWeapon`, `Misc`.
## ✅✅ The object is `PlayerParams`, and `sub_822F9498` is ITS loader — not the unit loader
Resolving **every** string `sub_822F9498` references, in code order, gives **90**
of them, and they are **exactly this object's schema, in this object's record
order**:
```
CollisionEffectName … EffectPlacementFrameName (Misc, 5)
SpecialAttack + 17 fields
TacticalManeuver + 7
SpecialWeapon + 8
Booster + 50
```
It never names `Generic`, `Maneuver`, `Explosion`, `Shield` or `StructureCount`;
the string `Maneuver` exists once in the image and has **0 xrefs**. The function
is called **exactly once**, from `sub_821A6CF0` — which is itself called once and
whose four strings include the literal **`PlayerParams`**.
⇒ 🔴 **Correction to [[unit-datasheet-static]], which calls `sub_822F9498` "the
unit-definition loader".** It reads one object, the player parameter table. Its
`AA_`/`AV_` interleave finding (`AV_` at `X`, `AA_` at `X+8`, five axes) still
stands as a **struct layout** — but the struct is **`PlayerParams`'s `Booster`
record**, not each unit's `Maneuver`. Whatever loads the 114 unit `Maneuver`
records does not use these name strings at all, and has not been found.
🟡 `name_hash`/`tag_hash` of `PlayerParams` under 7 prefixes × 6 spellings
(84 combinations) hits the object's key `0x890E1BE4` **0 times** — the string
names the table to the code, not the pak entry.
⚠️ The loader names **8** `SpecialWeapon` fields; the disc values **7**.
`HPRegenerator_Quantity` is named and never valued — the usual pattern.
## ✅ All five player craft fly identically; `Booster` differs on ten fields
Ranking all 114 units by how many of the 50 `Booster` values they reproduce:
the top five are **exactly the five `_Player` units**`f001_T`, two
`f001_T_Tt`, `f002_W`, `f004_A` — all at **39/50**, and the sixth-best unit drops
to **13/50**. **No unit matches 50/50.**
✅ And the five players **agree with each other on all 50 fields**: the three
player ships share one flight model. `Booster` stands alone on **10** of them
(the eleventh, `AA_Yaw_Max`, is `65.0` vs `65` — a formatting difference, not a
value; my earlier "11 differ" over-counted).
## 🟡 So what selects `Booster`? Nothing does, and that is the problem
`PlayerParams` is loaded **once, unconditionally**, at startup: one call site,
one caller, no branch. So `Booster` is not a profile the game *chooses* — it is
the only flight table this path loads.
🟡 **That is in tension with the measurements and I have not resolved it.**
[[flight-speed-law]] clocked the player at ~125 / ~420 / ~1 530, which tracks the
unit `Maneuver` 100 / 350 / 1 200 at a flat ~1.25 and misses `Booster`'s
100 / 612 / 2 100 badly — and the `RT`-held run reached ~1 530, not anything near
2 100. Both tables cannot govern the same craft. **Cheapest next test is a
runtime one:** watch which of the two constant sets reaches the live flight
struct, and re-measure with the afterburner held.
## ✅✅ SOLVED — `sub_821A6CF0` reads the analog block, found by solving the base
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 **261** such functions image-wide
(**277** before the `r0` correction at the end of this file).
**The answer: `sub_821A6CF0`, `r29 = 0x820A1630`, 22 of 24 displacements.** In
code order it names
```
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
AnalogRevice_adv_roll … AnalogRevice_throttle (8 curve names)
GP_MAIN_GAME
```
— 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.
🔑 **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.
> 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`,
`0x820AA654`) is referenced, by **`sub_822AE628`** — which also references
`ControlTweak`, `Camera`, `Rendering`, `BackGround`, `WeaponChangeFrame` and the
DOF/bloom field names. And **`ControlTweak` records exist only in
`GP_HANGAR_ARSENAL.pak` (×6)**, while `Tweak` exists only in the six main-game
paks (×1 each). `sub_822AE628` reads the **hangar's** control table. Taking it
for ours would have been a clean, wrong answer.
## ✅ The Stage 16 boss — object `0202269d`
Records `Guardian` (19 fields) and `Core` (10). `Guardian` names its three
weapons by shell id — `Shell_S16Boss_AAGun`, `Shell_S16Boss_Laser`,
`Shell_S16Boss_HBeam` — which is what identifies the object: **Stage 16**, the
stage `stage-settings-table.md` singles out via its unique `IsBoss16Enable`
field. `Guardian` HP 65 000 with `DamageLevel1/2` at 35 000 / 20 000; `Core`
HP 42 000 with levels at 25 000 / 10 000 and a lock-on release cycle
(`LockOnReleaseInterval 15.0` ± `LockOnReleaseRandomInterval 5.0`).
## 🟡 The fourth: object `a6c6c79b`
One record, `Generic`, one field — the **name** `eff_n0071` with an empty value.
An effect id and nothing else. Not identified.
## ✅ Classifying all rows objectively — 50 are data-table schemas (2026-08-27)
The earlier pass classified by eye and by function. This one is **by row** and
uses an objective test: **are the row's names IDXD record/field names on the
disc?** (13 450 of those disc-wide.) `name_block_bases.py` now prints it.
| | rows |
|---|---:|
| **≥50 % disc names, ≥8 names — a data-table schema** | **50** |
| everything else (engine/XDK vocabulary, key lists, noise) | 211 |
⚠️ Written as 53 / 224 of 277; the `r0` correction below withdrew 16 rows, three
of them from the 53. The two axes below are quoted at their original values.
Split against the base-confidence axis: `solved` bases (non-zero low half) split
34 / 136, `round` bases 16 / 91 — so **a round base is not the same question as a
non-table row**, and both axes are needed.
✅ The 53 include every loader the corpus already knows, which is the control.
**What they also include, still unopened** (each grepped: the noun appears in
no `docs/re/` file):
| row | names | what it looks like |
|---|---:|---|
| `sub_823BDAA8` r11 | 33 | `rou_e901` + `GN_MainGun_01_MuzC`, `GN_MainGun_02_Muz01…` — the **S16 boss's muzzle/attach frames**, next to the collision table above |
| `sub_823BDAA8` r10 | 25 | `Motion_stand`, `Motion_stand_b1`, `Motion_attackA_start`… — **motion names**, the `EnumMotions` family `DefTables` declares |
| `sub_82315AE8` r11 | 20 | `InitRotation`, `MaxRotationSpeed`, `RotationAccel`, `MaxVerticalSpeed` — the **`Guardian` record's own fields**, i.e. the S16 boss loader |
| `sub_8219E560` r11 | 18 | `Detail_Rank`, `MISSIONS`, `DETAIL_TITLE`, `Detail_Board_Permanent` — the **leaderboard screen** keys |
| ~~`sub_825F2CF0` / `sub_825F2F88` r0~~ | ~~30 each~~ | 🔴 **WITHDRAWN — not a name block at all.** See the `r0` correction below |
⚠️ Four rows that *look* new are not: `sub_82297550`, `sub_822A2F00`,
`sub_822A9C18`, `sub_82288028` mix arsenal fields the corpus owns
(`ConditionToDevelop`, `WeaponDesc` → [[arsenal-develop-economy]];
`SilhouetteModel` → [[arsenal-item-weapon-chain]]) with **literal screen
coordinates as strings** — `757,228`, `903,343`, `1092,457` — which is why their
disc-overlap sits at 5370 % rather than ~100 %.
## ✅ Mining the index: what the base-solver's index actually contains (2026-08-27)
277 rows over **190 distinct functions** — a function can read more than one
block — or **261 over 176** after the `r0` correction below. Classified:
**🔴 The tool's false-positive mode, now named.** 107 rows solve to a base on a
**64K boundary** — a bare `addis rX, r0, 0xHHHH` with no `addi` of its own, so any
scatter of displacements votes for it. **82 of them are `0x820B0000`**, and they
are ~60 near-identical functions in `0x8281xxxx0x8284xxxx` all "naming" the same
`rou_e007 rou_e010 rou_e015 …` list. Read a round base with its resolution ratio,
never on its own. The 170 rows with a non-zero low half are the trustworthy set.
**Already owned** (the index re-derives them, which is the point): the unit
loader `sub_82341A20` (217), stage settings `sub_8230D1F8` (129), `PlayerParams`
`sub_822F9498` (90), the hangar `sub_822AE628` (81), squadron orders
(`sub_82320B48``ORDER_WINGMAN_*`), missile guidance (`sub_8236B608`,
`sub_8237BB78``st1_up_aperture` etc., [[weapon-datasheet-static]]), shell
movement (`sub_82261F70``Spiral_BeginTime`, [[weapon-struct-runtime]]),
substructures (`sub_823479B8``ParentStructureID`), and ~~six~~ **three** camera/fog readers
(`sub_8247DFC0` r31, `sub_823B2620` r31, `sub_822C7480` r31 — all three bases in
the same `0x8209E6xx` region; the other three were `r0` artefacts, below).
**🔑 The find: `sub_8233C368` is the `AIParams` loader.** `r28`, base
`0x8208583C`, 20 names — `Enumerate_AIs`, `FiringLength`, `GuardLength`,
`AutoGuardLength`, `CounterLength`, `MusterLength` and the 14 manoeuvre weights.
⚠️ **Corrected:** I first wrote that this unblocked a NEEDS-HUMAN item.
[[stage-mission-tables]] already had `AIParams` as ✅ static and directly
portable, with every field name; **only the loader was unnamed.** The disc-wide
census that followed is in that document. The same base `0x8208583C` also serves
`sub_82338EE0` (97 names, `Weapon TargetType SpecialWeaponType ReticleType
IsCharging …`) — the **weapon** datasheet loader, likewise not previously named.
⚠️ **CORRECTION (2026-08-27) — a row is a (function, REGISTER) pair, not a
function.** My one-line labels for those blocks each quoted **one of two** rows,
and the other row of the same function is a different schema entirely:
| function | one row | the other row |
|---|---|---|
| `sub_822E3EC8` | `r11` 29 — shader constants + techniques | `r10` 15 — the material **map slots** |
| `sub_822AFA50` | `r11` 16 — XDK shader-compiler tokens (`__vspltw`, `__vsel`) | `r10` 13 — the **menu camera tags** |
| `sub_823AE908` | `r11` 43 — the **S16 boss collision** table | `r31` 33 — shader tokens (the dense-block false positive) |
**Read `docs/re/data/name-block-bases.txt` by row, and quote the register.**
The three findings themselves are written up where they belong:
[[xbg7-mesh]] (material slots + shader constants) and [[collisionset]] (the S16
boss's 23 collision parts over 20 meshes). `sub_822AFA50`'s camera-tag row is
13 `roh_n001_menu{1,2}_*_cam_{pos,tag}` names — the menu scene's camera
positions and look-at tags, with `menu2` having sub-shots `2_0``2_4b`.
🟡 None of those five were opened; the index says what each names, not what each
means.
## 🔴 CORRECTION (2026-08-28) — `r0` is not a base register; 16 rows withdrawn
The last shortlisted row was `sub_825F2CF0` + `sub_825F2F88`, **`r0`**, 30 names
each on the same base `0x8209EB30`, 97 % of them disc names. The question I set
was "a pair, or a clone?" **Both answers are wrong: neither function reads a name
block.**
Diffing the two, instruction for instruction, settles it in one look — 145
instructions each, **72 differ and every difference is the same substitution**:
```
825F2CF0 addi r11, r0, -288 825F2F88 addi r11, r0, -288
825F2CF4 stvx v14, r11, r12 825F2F8C lvx v14, r11, r12
825F2CF8 addi r11, r0, -272 825F2F90 addi r11, r0, -272
825F2CFC stvx v15, r11, r12 825F2F94 lvx v15, r11, r12
… stvx / stvx128, v14 … v127 … lvx / lvx128, v14 … v127
```
They are the compiler's **VMX register save / restore helper pair** — a spill of
v14v127 and its matching reload. So they *are* a pair rather than a clone, but
the pairing is `stvx`/`lvx`, not two readers of one table.
🔑 **The root cause, and it is a class of false positive, not one row.** In
PowerPC `addi rD, rA, N` the `rA` slot **reads as literal zero when it names r0**
the form is `li rD, N`. The 72 `addi r11, r0, -N` are the vector spill offsets,
plain immediates. The solver had excluded `r1` (the stack pointer) and nothing
else, so it took those offsets as displacements, voted a base, and landed
`0x8209EB30`**inside the stage-settings name block**, where a scatter of
16-byte-spaced negative offsets is dense enough to "resolve" 30 real field names.
**A 97 %-disc-names row can still be an artefact.** The disc-name test measures
what the *base region* contains, not that the function reads it.
**Nothing is lost.** All 30 of the withdrawn names are a **strict subset of the
real reader's block**: `sub_8230D1F8 r29`, base `0x8209FD28`, 129 names —
the stage-settings loader the corpus already owns ([[stage-settings-table]]).
The string-xref join confirms it independently: `FinalPassBG`, `FogMinDistance`,
`ScreenColorR`, `ExposureKey_BeginValue` each exist **once** in the image and
each has **exactly one** xref, from `sub_8230D1F8`. `NoseCameraFOV` and
`GlareType` exist **twice** — the second copy is read by `sub_822AE628`, the
hangar's game-wide defaults, which is the trap the corpus already records.
**Fix and its effect.** `name_block_bases.py` now skips `reg in ('r0', 'r1')`.
Every number moves by exactly the 16 `r0` rows and nothing else:
| | before | after |
|---|---:|---:|
| rows | 277 | **261** |
| distinct functions | 190 | **176** |
| bases with a non-zero low half | 170 | **154** |
| bases on a 64K boundary (low confidence) | 107 | 107 |
| data-table schemas (≥50 % disc, ≥8 names) | 53 | **50** |
Both ways: `154 + 107 = 261`, and `277 16 = 261`. The diff against the previous
artefact removes 100 lines and adds 3; **every removed row line carries `r0`**,
the three added lines are those counts, and the control is untouched
(`sub_82341A20 r30 = 0x82088F94`, 217/226). Two of the 16 functions keep a row
through another register, which is why 190 176 = 14, not 16.
The three withdrawn data-table rows are `sub_825F2CF0`, `sub_825F2F88` and
`sub_82222E70` (15 names, 100 % disc — the same `0x8209E6D8` stage-settings
region, and by the same mechanism).
🟡 What this does **not** settle: the 🟡 stalled `SpaceSize` / `GlareType` /
`Nebura_*` numerics are no closer — their reader was already known to be
`sub_8230D1F8`, and the r0 row never was a second one.
## 🔴 CORRECTION (2026-08-28) — the confidence axis was inverted
Having killed the `r0` rows, the other named false-positive mode was "**107 rows
solve to a base on a 64K boundary — a bare `addis rX, r0, 0xHHHH` with no `addi`
of its own, so any scatter of displacements votes for it**". That reasoning is
wrong, and the measurement says so.
**The test.** The solver now simulates `lis`/`addis rD, r0, HI`, `addi rD, rA, N`
and `or rD, rA, rA` forward through each row's own function and asks whether the
**solved base ever lands in the solved register**. That is positive confirmation
from the instruction stream, independent of the displacement vote.
| | confirmed |
|---|---|
| bases on a **64K boundary** ("low confidence") | **107 / 107** |
| bases with a non-zero low half ("trustworthy") | **8 / 154** |
A 64K-boundary base is not evidence of a weak vote — it is the case where the
compiler needed **no second instruction**, so `addis r11, r0, 0x820B` stands in
the code in full and the base can be read off directly. A non-zero low half
usually means the base was built in the **caller** or loaded from memory, which
the simulation cannot see; a miss there is silence, not a refutation. (The
control, `sub_82341A20 r30 = 0x82088F94`, is one of those silences — it is
validated by its 217/226 resolution and by the disc, not by this test.)
🔑 **The control that settles it: every row the corpus independently validated
against the disc has a 64K-boundary base.**
| row | base | what it is, verified elsewhere |
|---|---|---|
| `sub_822814D8` r11 | `0x820B0000` | debriefing keys, 21 `STAGE_RESULT` fields |
| `sub_8227A3A0` r11 | `0x820B0000` | career keys, `OVERVIEW` 9 / `EX_OVERVIEW` 7 |
| `sub_82286BC8` r11 | `0x820B0000` | save-screen keys ([[savegame-format]]) |
| `sub_8219E560` r11 | `0x820A0000` | leaderboard, 13 records + 5 fields |
| `sub_822215D0` r11 / r10 | `0x820A0000` | the 205-name `PG*` HUD roster ([[hud-config]]) |
| `sub_822E3EC8` r11 / r10 | `0x820B0000` | shader constants + material slots ([[xbg7-mesh]]) |
| `sub_823AE908` r11, `sub_823BDAA8` r11 / r10, `sub_823C0260` r11 | `0x820B0000` | the S16 boss collision, frames and motions ([[collisionset]], [[stage16-boss]]) |
| `sub_82315AE8` r11 | `0x820B0000` | the S16 boss loader, 19/19 field names |
Thirteen rows over ten functions. Meanwhile the dense-short-string false
positives the corpus *did* name — the `r31 = 0x8202xxxx` rows on
`sub_82341A20`, `sub_8230D1F8`, `sub_822F9498`, `sub_823AE908` — all sit in the
class that was called trustworthy. **The axis pointed the wrong way in both
directions.**
### ✅ What the 0x820B0000 cluster actually is: duplication, not error
82 rows carry base `0x820B0000`, and **60 of them are one function emitted 60
times**. Each is **exactly 491 instructions**; diffing any two, **two
instructions differ**, both of them a global data pointer:
```
9 828110FC addi r30, r11, 14272 | 8281250C addi r30, r11, 15552
487 82811874 addi r3, r11, -13024 | 82812C84 addi r3, r11, -12984
```
Their sequences of string addresses are **identical, all 60** — the same 40
resource names in the same order, written into a per-copy global through
`sub_8217FA08(dest, name, -1)` in 24-byte strides. That is a static array
duplicated once per translation unit, not sixty tables. The index is inflated by
**duplication**, which the resolution ratio cannot show and the confirmation test
does not care about.
The roster itself: 30 `rou_*`, 6 `mob_*`, 3 `rob_*`, 1 `rot_*`. **38 of the 40
are `GameResourceID` values on the disc** (480 distinct, each used once) —
[[collisionset]] owns that prefix vocabulary and the `GameResourceID` link; what
it does not say is that the image compiles a fixed 40-name subset of them.
🟡 The two that are not: **`rot_n001`** — and no disc `GameResourceID` uses the
`rot_` prefix at all (`eff` 197, `rou` 172, `mob` 71, `wep` 23, `rob` 4, `stg*` 9)
— and **`rou_e202`**, whose siblings `rou_e201` and `rou_e901` are both present.
Two names the image carries that this disc field never uses.
🟡 Not settled: what the 60 globals are read by, and what selects 40 of the 172
`rou_*` ids.
## ✅ What the 60 duplicated functions are (2026-08-28)
Following the duplication finding above: each of the 60 is a **static-object
constructor**, and the object it builds is a **40-element array of 24-byte string
objects**.
```
8280FE20 addis r11, r0, 0x820B ; the string literal
8280FE28 addi r4, r11, -9584
8280FE34 addi r30, r11, 12944 ; r30 = 0x828E3290, the array
8280FE3C bl 0x8217FA08 ; assign(dest, literal, -1)
8280FE40 li r11, 0 … stw r11,4..20(r30) ; zero the rest of the object
8280FE64 addi r3, r30, 24 ; next element, stride 24
```
**Why they have no callers.** Each clone appears exactly **twice** in the whole
image: once in the `.pdata` unwind table — where every function appears, as
`(address, prolog/length word)`, and reading that as a registry is a trap — and
once as a slot of a **null-terminated 182-entry function-pointer array at
`0x82870018``0x828702EC`**. **170 of those 182 targets have zero direct xrefs**,
so the array is dispatched indirectly: it is the static-initialiser list, run
before `main`. Zero callers here means "static ctor", not "dead code".
**The teardown side confirms the shape.** Between two consecutive clones sit
**3943 other functions (median 40)**, and the dominant size in the whole
`0x8281xxxx``0x8284xxxx` region is **11 instructions, 2 280 of them** (60 × ~40).
Reading three consecutive ones:
```
sub_828105BC: r3 = 0x828E3290 -> bl sub_823F3D68
sub_828105E4: r3 = 0x828E3290 + 24 -> bl sub_823F3D68
sub_82810610: r3 = 0x828E3290 + 48 -> bl sub_823F3D68
```
One thunk per element, same 24-byte stride, same global, all with zero xrefs —
the per-object destructors registered at exit. So the pattern is **one ctor
writing 40 names + 40 one-line dtor thunks, emitted 60 times**: a template or
macro-generated static array, not sixty tables.
🟡 What this still does not say: **which code reads the arrays**. An exact-address
search finds 2 347 functions outside the 60 that materialise one of the 60 global
addresses — far too broad to be a consumer list, and the region is a dense global
data area, so that search has no power here. The consumer needs a different route.
### ✅ `rot_n001` and `rou_e202` are on the disc after all
Both are absent from the 480 `GameResourceID` values, but a raw byte search across
every decompressed pak entry finds **`rot_n001` 26 times in 7 archives**
(including `DefTables.pak`) and **`rou_e202` 6 times in 6** — and those six are
exactly one per `GP_MAIN_GAME_<lang>` pack, with none in `DefTables`. Controls on
the same sweep: `rou_e201` 151 / 8 archives, `rou_e901` 917 / 8, `mob_n500` 46 / 8.
So neither is an image-only name; **`rou_e202` is simply the rarest member of the
roster**, carried once per language pack. 🟡 Which field holds them is not settled.
## 🔴✅ The roster's two vocabularies — and a prefix match I mistook for a hit (2026-08-28)
🔴 **Correction first.** Last iteration's raw byte sweep reported `rot_n001` "26
times in 7 archives". That was a **substring** count. Matching the *exact* field
value gives **zero**, in every IDXD record and every non-IDXD payload. Expanding
the surrounding characters shows what the disc actually carries:
```
rot_n001_break x14
LOD_Frame_rot_n001_break x7
GameModel_rot_n001_break x5
```
**`rot_n001` exact does not exist on this disc; `rot_n001_break` does**, and it is
itself one of the `Generic.Model` values. The image names the stem, the disc names
the `_break` variant. **A prefix match is not an exact match** — the corpus's own
rule, and the byte sweep is exactly the tool that breaks it.
**`rou_e202` does exist**, in the *other* vocabulary: it is a **`Model` field of
a `Generic` record**, six times — one per `GP_MAIN_GAME_<lang>` pack — and it has
no `GameResourceID`. That is why the `GameResourceID` census called it absent.
**The roster is covered by two disjointly-populated fields.** Exact-value sets
across every IDXD record on the disc:
| set | size |
|---|---:|
| the compiled roster | 40 |
| `Generic.Model` values | **46** |
| `GameResourceID` values | 480 |
| roster ∩ `Generic.Model` | 34 |
| roster ∩ `GameResourceID` | 38 |
| roster ∩ **both** | 33 |
`38 + 34 33 = 39`**39 of the 40 are one or the other**, and the single
uncovered name is `rot_n001`, for the reason above. The six roster names that are
`GameResourceID` but not `Generic.Model` explain themselves:
`mob_n040``mob_n043` are the four **`AsteroidGroup_00N.AsteroidModelName`**
values (18 each, one per pack — asteroid models, not units), and `rou_e004` is a
`Level_0.Model`, not a `Generic` one.
🟡 **What selects the 40 is still open, and two candidate answers are now dead.**
It is not the `Generic.Model` set (34 of 46) and not the `GameResourceID` set
(38 of 480). The 12 `Generic.Model` values *outside* the roster are `mob_n050`,
`rob_e005`, `rot_n001_break`, `rou_e001`, `rou_e002`, `rou_e016`, `rou_e017`,
`rou_e018`, **`rou_f002`, `rou_f004`**, an empty string, and a Shift-JIS literal
`文字列` ("character string" — a template placeholder, the `_Test` pattern again).
**`rou_f002` and `rou_f004` are the player's own craft** for S7S16 + S2427,29 and
for S28 ([[stage-numbering-and-player-craft]]), and the roster omits both while
keeping `rou_f001` — so "the playable or most-used models" is dead too.
[[unit-substructure-records]] mentions `Generic.Model` as "the mesh the corpus
already decodes"; what it does not say is that there are only **46 distinct**
values of it disc-wide. `AsteroidGroup_00N` / `AsteroidModelName` are new nouns.