# ✅ 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 53–70 % 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 `0x8281xxxx–0x8284xxxx` 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 v14–v127 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 **39–43 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_` 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_` 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 S7–S16 + S24–27,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.