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/docs/re/structures/player-tuning-tables.md
Sylpheed RE agent 9480f0aa8a 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

30 KiB
Raw Blame History

The player-tuning tables — analog response curves, booster/flight limits, special attacks

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 constantsmov_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 unitsf001_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): 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 PlayerParamssub_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, WeaponDescarsenal-develop-economy; SilhouetteModelarsenal-item-weapon-chain) with literal screen coordinates as strings757,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_82320B48ORDER_WINGMAN_*), missile guidance (sub_8236B608, sub_8237BB78st1_up_aperture etc., weapon-datasheet-static), shell movement (sub_82261F70Spiral_BeginTime, weapon-struct-runtime), substructures (sub_823479B8ParentStructureID), 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_02_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 0x8209EB30inside 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 0x828700180x828702EC. 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 0x8281xxxx0x8284xxxx 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 = 3939 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_n040mob_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.