Files
Syplheed-Reborn/docs/re/structures/unit-struct-runtime.md
Claude (auto-RE) 6d0bdf0179 re(units): S08 closes the story stages (68 units), and GAME_CLEAR unlocks nothing
S08 gave the last two story-stage units — UN_be005_ADAN_SpaceFortress and
UN_mn500_ADAN_FloatingMine — plus six S08 asteroid collision meshes: 68 units,
9 393 rows, 7 115 defaulted-on-disc values. Every unit any story mission fields is
now read (689 more disc cross-checks, 0 disagreements).

Probe recorded as a negative: setting Game Status (GHAD +48 and its header mirror
+0x18) to 2 = STATE_GAME_CLEAR leaves both menus unchanged — the title still shows
NEW GAME / LOAD GAME / TUTORIAL / OPTIONS / EXTRAS and EXTRAS still shows only
MISSION SELECT / MOVIE THEATER / BACK. So the EX/challenge rosters are not gated on
that field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 14:39:05 +00:00

28 KiB
Raw Blame History

Runtime Unit struct (craft / vessel definitions) — read from live guest memory

Confidence: CONFIRMED for the 27 fields marked below (each binding is reproduced by 1021 independent disc records, on ≥3 distinct values, with zero contradictions), plus the Maneuver block's declaration-order layout (29 anchors, two bases, no conflicts). 🟡 PROBABLE for the fields interpolated between confirmed anchors. 🟡/ for the thin single-value bindings, which are listed but must not be trusted yet.

Captured 2026-07-29 from Xenia Canary running the retail disc in the sylph-re container: all six tutorials and Stage 02 "Declaration of War" loaded from save slot 01. 21 of the disc's 110 units, over 7 snapshots from 7 separate emulator runs.

Why this exists

Craft stats were the one Route-B target the previous two passes could not reach. The Hangar exposes only Gross Weight (a class, not a number), and the menu route has no surface at all for the flight model. From the memory side, menus were equally useless: only the player craft's name string is resident there — the definition objects do not exist until a mission loads.

They do exist in-mission. This documents their layout and the values the disc leaves defaulted.

Finding the class (discovered, not assumed)

tools/re-capture/unit_discover.py takes no vtable as input. It locates every disc unit-ID string in a memory snapshot, finds every aligned word pointing at string_va - d for a range of d, and tallies the word at pointer_site - k across distinct unit IDs. One (d, k, word) combination wins by a wide margin:

δ (name-record → string) ID pointer at word distinct units
0x10 object +0x04 0x820af030 4
0x10 object +0x04 0x820af844 4

— i.e. exactly the Weapon shape: the object holds a name-record pointer at +0x04, and the ID string sits at name_record + 0x10.

The two vtables are two different things, and telling them apart matters:

vtable what it is evidence
0x820af030 spawned entity record — one per spawned thing, but not live state (see below) 12 objects for 4 IDs; the same ID appears many times (one per box in the scene); irregular spacing
0x820af844 parsed definition — the .tbl exactly one object per distinct unit ID; minimum spacing 0x380

Correction (2026-07-29, from the autopilot work): 0x820af030 was described here as holding live state. It does not — across a 29 s in-flight capture all 384 words of it are constant. It is one record per spawned thing, but the flying entity's transform is somewhere else entirely. See autopilot-memory-driven. Nothing below depends on it; the definition class 0x820af844 is unaffected.

Only 0x820af844 is used. It is the runtime image of the .tbl:

  • Within one run it is byte-identical across two snapshots taken ~12 minutes apart with combat in between (14/14 objects, 0 differing bytes) — definition data, not live state.
  • Across runs the same unit is not byte-identical, and that had to be explained rather than waved away. unit_runtime.py --crosscheck compares every unit that appears in more than one snapshot (5 of them, over 7 runs): exactly 15 words differ, and 13 of them hold guest pointers (0x8xxxxxxx/0xbxxxxxxx — heap addresses, which move per process). No solved or interpolated field offset is among the 15 — every value reported here is run-invariant.
  • The two non-pointer stragglers, +0x2c8 and +0x2d0, are stage-dependent: for one and the same unit (UN_f001_TCAF_DeltaSaber_T_Ttrl) +0x2c8 reads 8000.0 in two tutorials and 10000.0 in a third, with +0x2d0 a 0/1 flag beside it. So the object is mostly but not entirely the parsed table — a couple of words are set per stage. Unidentified; NEEDS-HUMAN.

One .tblone object. A unit table is several sub-records (Generic, Maneuver, Shield, Explosion, Mass, Effect, SE, Turret_00N), and they are all flattened into that single ≥0x380-byte object — unlike weapons, where Weapon and Shell are separate arrays.

Solving the layout

Same discipline as weapon_runtime.py: score every (field, byte-offset, encoding) triple against the disc records and accept a binding only on zero contradictions, requiring ≥3 distinct values so a field whose samples are all one number cannot match any offset holding that constant.

python3 tools/re-capture/unit_runtime.py unit_tokens.txt snap_a.bin snap_b.bin --csv

Snapshots are unioned — each mission instantiates only the units in its own stage, so coverage grows by visiting stages. cp --sparse=always a copy of /dev/shm/xenia_memory_* first (~2 s); the running emulator pegs every core under lavapipe and makes repeated live reads flaky.

Encoding note — angles are radians at runtime

Every AV_* / AA_* / *Bank* / Turn_AngularVelocity field is stored as float32 radians, while the disc writes degrees. The solver needed a rad encoding (degrees(f32)) to bind them at all; 18 units agree on AV_PitchPlus_Max alone. A reimplementation reading the .tbl must convert.

Confirmed layout

= ≥10 disc records agree on ≥3 distinct values, zero contradict.

offset enc field agree distinct
+0x030 f32 Size_X 21 13
+0x034 f32 Size_Y 12 7
+0x038 f32 Size_Z 19 13
+0x040 f32 Color_R 21 5
+0x044 f32 Color_G 19 6
+0x048 f32 Color_B 17 5
+0x050 f32 Size_Radius 12 10
+0x054 f32 HP 19 10
+0x074 f32 ResistanceToOptics 11 3
+0x08c f32 ScorePoint 21 9
+0x094 f32 MassScore 10 7
+0x09c f32 MinimumVelocity 11 3
+0x0a0 f32 MaximumVelocity 19 8
+0x0a4 f32 CruisingVelocity 17 7
+0x0a8 f32 Acceleration 18 5
+0x0ac f32 Deceleration 17 5
+0x0b0 rad AV_PitchPlus_Max 18 7
+0x0b4 rad AV_PitchPlus_Min 10 6
+0x0c4 rad AV_PitchMinus_Min 10 5
+0x0f8 f32 SideThrustVelocity_Max 14 3
+0x238 f32 MaxValue (Shield) 13 6
+0x244 f32 ChargeSpeed (Shield) 13 6
+0x270 f32 DestroyMotionTime 19 7
+0x2a0 f32 RadarRange 18 9
+0x2a4 f32 FCSRange 12 8
+0x2b4 f32 AttackVesselPoint 14 8
+0x2bc f32 DefencePoint 12 7

HQRatio +0x058, ShieldRatio +0x05c, ThrusterRatio +0x060, ResistanceToShell +0x078, ResistanceToExplosion +0x07c, ResistanceToPlayer +0x080, ResistanceParalyze +0x084, BridgeCount +0x070 (i32), DryMass +0x274, GrossMass +0x278, LowerHPThresholdRatio +0x298, AttackCraftPoint +0x2b8 bind with zero contradictions on fewer records or fewer distinct values — 🟡 PROBABLE. The full solver output is in captures/unit-runtime-fields.csv (1 827 values, conf column).

The Maneuver block is laid out in schema declaration order

This is the strongest structural result and it is independent of any single field's agreement count.

schema_order.py merges the Maneuver field-name order from all 113 unit tables by topological sort over their pairwise "k[i] precedes k[i+1]" constraints. The merge is acyclic and every one of the 113 tables is a subsequence of the merged 102-field order — so that order is the schema's.

Against it, the solved offsets fall into two exact runs:

declaration indices offset rule anchors that fit
0 … 20 (MinimumVelocityAA_Roll_Min) 0x09c + 4·i 21 / 21
21 … 32 (SideThrustVelocity_MaxAccPitchFactor) 0x0a4 + 4·i 8 / 8

One 4-byte slot per field, with a two-slot gap after AA_Roll_Min (0x0f00x0f4, purpose unknown). 29 independently-derived anchors, zero conflicts, across a 0x9c0x125 span.

Fields NO disc record ever values — 🟡 PROBABLE

Five Maneuver fields are declared by the schema but left at their default by every one of the 110 unit tables, so no amount of disc analysis can ever reach them. The declaration-order rule pins them between confirmed anchors (YawDragFactor +0x104ArterBurner_Vc +0x114, and ReverseThrust_Vc +0x118ReverseThrust_Acc +0x120):

offset field craft (f00*, e0*) capital ships (*1**, e2*) inert (SchlosBase, Box)
+0x108 PitchDragFactor 3 2 0
+0x10c RollDragFactor 3 2 0
+0x110 DragFactorThreshold 0.5 0.5 0
+0x11c ArterBurner_Acc 2 2 0
+0x128 DecPitchFactor 30 30 0

Corroboration beyond the interpolation, checked over all 18 units:

  • PitchDragFactor == RollDragFactor == YawDragFactor holds 18/18 — and YawDragFactor is disc-supplied (3.0 for craft, 2.0 for warships), so two interpolated offsets reproduce a known number, per unit, every time.
  • DecPitchFactor == AccPitchFactor holds 17/18. The exception is UN_e015_ADAN_Puppy (AccPitchFactor 1, DecPitchFactor 0.5) — which defaults both on disc, so it is two independent fields that happen to be set equal elsewhere, not a broken binding.

UN_e015_ADAN_Puppy also breaks the craft/warship bucketing above for DecPitchFactor (0.5, not 30); the per-unit values are in the CSV.

The tail of the Maneuver block (the AI-behaviour fields — SideRoll_*, BarrelRoll_*, TurnAttack_*, HoldPosition_*, Slalom_*, Through_*, SolidCutoff_*, and the AxisMode / AB_* sub-block) is NOT resolved. Those fields are declared by only a handful of tables and almost always with a single distinct value, so the solver's bindings there are coincidences: it placed Slalom_CutoffRatio at +0x00c and TurnAttack_DoubleTimeMin at +0x110, both of which the declaration-order rule contradicts. They are marked tentative in the CSV. NEEDS-HUMAN / needs more coverage — more stages would give those fields distinct values and settle it.

The player craft, UN_f001_TCAF_DeltaSaber_T_Player

30 of its fields are defaulted on disc. Notable recovered values:

field value note
Size_Radius 10
FCSRange 500000 — same as RadarRange
ChargeSpeed (shield) 25
ResistanceToOptics 1
HQRatio / ShieldRatio / ThrusterRatio 1 / 1 / 1 🟡
ResistanceToShell / ResistanceToExplosion 1 / 1 🟡
DryMass 100 🟡 (GrossMass 250 is on disc)
LowerHPThresholdRatio 0.3 🟡
ChargeDelay_Break 10 🟡
MassScore 0 🟡

Every AI-behaviour field the solver bound reads 0 for the player craft, which is the expected shape (the player is not AI-driven) — but see the caveat above: those offsets are not settled, so treat the zeros as consistent, not proven.

The …Ratio family that the Route-B target list parked is 1.0 for almost every unit, with real exceptions that only the runtime shows: UN_e105_ADAN_Cruiser HQRatio = 0.2, UN_bf001_TCAF_SchlosBase and UN_e106_ADAN_Destroyer ThrusterRatio = 0.2, UN_n001_TTRL_Box ShieldRatio = 0.3.

Coverage and how to extend it

21 of 110 units. Unlike weapons — where one snapshot held all 126 — unit definitions are instantiated per stage, so coverage is bounded by the stages reachable from the save (slot 01 is at 5 %, Stage 02). unit_runtime.py unions any number of snapshots and re-solves, and more units directly promote the 🟡 bindings to by adding distinct values — the six tutorials took the confirmed set from 22 fields to 27.

The tutorials are nearly exhausted as a source: all six together contribute only 3 units the missions do not already have (UN_f001_TCAF_DeltaSaber_T_Ttrl, UN_e015_ADAN_Puppy_2, UN_f001_TCAF_DeltaSaber_T_Player_Ttrl2) — they reuse one training box, one target drone and the player craft. Real coverage now needs real missions, i.e. story progress on the save.

tools/re-capture/grab_tutorial.sh captures one tutorial per invocation (cold boot → menu → Nth entry → snapshot, ~2.5 min). It cold-boots for each because backing out of a loaded mission via PAUSE → BACK TO MENU wedges the emulator. Two timing facts it encodes, both learned the hard way: the main menu is not input-ready for ~10 s after the title tap, and d-pad presses before that are silently dropped — which sends the A to NEW GAME instead of TUTORIAL. And NEW GAME is not a cheap way to reach Stage 01: it gates on a DIFFICULTY menu and then plays the prologue movie.

A NEW GAME excursion as far as the READY ROOM leaves 535107D4/00000001/game01/savedata byte-identical — only the profile .gpd achievement files change — so it does not endanger the 5 % save. Verified by diff against a backup, not assumed.

A stage's whole unit set is parsed at load, not as waves spawn — checked by counting the definition objects at three points in Stage 02: immediately after take-off, ~12 minutes in mid-combat, and after GAME OVER. 14 objects, the same 14 IDs, every time. So capturing a stage costs one load and one snapshot; there is no need to play it, and no need to survive it.

Stages captured so far: Ttrl (BASIC CONTROLS), Stage 02.

A defaulted unit field is not a global constant — some inherit from a sibling

Confidence: 🟡 for Size_Y, for the rest. Analysis 2026-08-10, offline, from captures/unit-runtime-fields.csv.

The coverage limit above (21 of 110 units, growing only with story progress) is worth attacking from the other side first: if a field the disc leaves unset always took the same runtime value, the 21 captured units would pin that default for all 110 and no further missions would be needed.

It does not. Restricting to the 150 values that are both CONFIRMED and come from a field the disc leaves defaulted, only 6 of 24 fields have a single value across every unit that defaults them (HP→10, MassScore→0, MaximumVelocity→0, RadarRange→0, DestroyMotionTime→0, Size_Z→0.1). The other 18 take several distinct values — so the default is computed per unit.

Where from? For each defaulted value, ask which other field of the same unit holds exactly that value. Counting only cases where the value is non-zero (otherwise 0 == 0 inflates every pair) and checking that the two fields are at different offsets (so the match is not the layout solver aliasing them):

defaulted field takes the value of support independent units
Size_Y (0x034) Size_X (0x030) 9/9 7, 6 distinct values
Size_Radius (0x050) min(Size_X, Size_Z) 4/4 4, 3 distinct values
FCSRange (0x2a4) RadarRange (0x2a0) 4/4 2
DefencePoint (0x2bc) AttackVesselPoint (0x2b4) 6/6 2

Size_Y ← Size_X is the one to trust: seven unrelated ships (e105 600, e106 300, e108 80, e201 300, f101 400, f105 700, f106 200) each omit Size_Y on disc and each shows its own Size_X at runtime. When both fields are on disc they differ freely (14 distinct Size_Y values against 13 of Size_X), so this is a default rule, not one value stored twice.

Size_Radius's formula is not yet separable: min(Size_X, Size_Z) and "the median of the three axes" fit all four units identically. UN_e010_ADAN_Attacker_S is what rules out the simpler Size_Radius ← Size_X (X=100, Y=40, Z=50, radius 50). The last two rules rest on two independent units each and are — recorded so they can be falsified, not relied on.

Why it matters for the reimplementation: filling a missing Size_Y with 0 or with a global constant gives the game's largest hulls a wrong lateral extent (f105 700, e105 600, f101 400 — all defaulted on disc). Applied across the disc, the rules recover 65 (unit, field) values in units that have never been visited: Size_Y in 21 of the 21 units that omit it, Size_Radius in 22 of 26, FCSRange in 14 of 56, DefencePoint in 8 of 60.

Cross-check against the weapons: this is NOT an engine-wide mechanism

The obvious worry is that four rules from 21 units are coincidence. The Weapon/Shell capture is the control: complete coverage, 126 records, with the same "defaulted on disc" classification. Running the identical sweep there (confirmed rows, non-zero values, offsets required to differ) finds no sibling rule at all — the single 100 %-agreement candidate (Shell.Length ← Shell.Volume, 5 records) has one distinct value, i.e. it is really the constant Length → 10coinciding withVolume = 10. Weapon defaults vary per record just as unit defaults do (10 of 14 Weaponfields, 16 of 17Shell` fields), so the phenomenon is general; the sibling explanation is not.

So Size_Y ← Size_X is specific to the unit schema (plausibly the size block defaulting its axes), not a property of IDXD default resolution. Two consequences: the rule cannot be justified by appeal to a general mechanism, and the two two-unit hypotheses (FCSRange, DefencePoint) lose the support they would have borrowed from one — treat them as coincidence-not-excluded until a new stage tests them.

Size_Y ← Size_X itself survives this scrutiny, and was re-checked at the raw token level rather than through the sub-record merge: UN_e105_ADAN_Cruiser, UN_f105_TCAF_Cruiser and UN_f101_TCAF_Acropolis each declare Size_X, Size_Z and Size_Radius and no Size_Y at all, and each reads back its own Size_X (600 / 700 / 400) at runtime.

How to falsify: the rules predict a specific number for units in stages not yet captured. Load any new stage, snapshot, and compare — one disagreement kills the rule. Note what is not a useful test: Stage 01, the only other reachable stage, adds just four uncaptured units (e010/e106 variants) whose predictions are the same numbers their already-captured base variants gave, so it would re-measure rather than test. A real test needs a stage with unfamiliar classes, i.e. story progress — which is now the only thing story progress is needed for here.

Cross-validated against the loader itself (2026-08-13)

This layout was solved by binding disc values to RAM words. It has now been derived a second time, independently, from the game's own code: the loader sub_82341A20 builds every key as addi r4, r30, -N (r30 = 0x82088f94), so the field name for each store is a string in the executable, and pairing each key with the first store after its accessor call gives the offset without any value matching. See live-unit-definitions.md.

The two agree completely where they overlap: 25 shared offsets, 25 agree, 0 disagree. Each also covers what the other misses — the code-derived table has 159 fields (vs 27 confirmed here) and is checked in as crates/sylpheed-formats/data/unit_definition_layout.txt with sylpheed_formats::unit_layout and a no-emulator regression test; this table still uniquely holds ScorePoint (+0x08c) and MassScore (+0x094), which the key-string extraction did not pick up.

⚠️ One tentative entry here is an artefact and should not be trusted. Slalom_CutoffRatio at +0x00c is recorded with values like 2.8026e-45 and 1.4013e-45 — those are the denormal float readings of the integers 2 and 1. +0x00c is below the first field the loader ever names (+0x018), and the same trap produced a false YawDragFactor → +0x0c hit when the code-derived map was being built. It is an int-typed word, not a ratio.

Stage select makes the coverage limit go away (2026-08-13)

Unit definitions are instantiated per stage, so this note's coverage figure (21 units, "grows by visiting missions") was blocked on progress. It no longer is: the save's stage field is solved (savegame), so any story stage can be flown from a hand-edited save and snapshotted.

First harvest — a mission at the Night Ravens (027 objectives, a different roster entirely):

before after
units with runtime values 21 26
rows in unit-runtime-fields.csv 1 827 2 143
defaulted-on-disc values 963 1 059

New units: UN_e004_ADAN_ElanPlus_N, UN_e006_ADAN_Vindicator_Margras, UN_f002_TCAF_DeltaSaber_W, UN_f002_TCAF_DeltaSaber_W_Player (a second player craft!) and UN_mn040_Asteroid_Big (47 defaulted values, Size_Z = 2000).

Two traps, both paid for in this run:

  1. launch_mission.sh does not load slot 01. The LOAD GAME list preselects the last-used slot — here 03 — and the script simply presses A on it, so a probe written to slot 01 is ignored and the game flies whatever that slot holds. The first "stage 14" snapshot was therefore stage 02 again, and the solver correctly reported 0 new units. Fix used: patch every slot (01/02/03) to the target stage, or drive the list explicitly and confirm the highlighted slot by screenshot before pressing A.
  2. Back up before patching, not after. The backup taken mid-run already contained the probe, so "restoring" it restored the probe; the pristine copy from the earlier session was what actually restored slot 01 (142b4f43…, verified).

Regenerating the solver's token file: idxd_tokens <GP_MAIN_GAME_E.pak> Generic emits the REC/F records unit_runtime.py wants — 110 of them are UN_*, which is the disc's full unit count.

Reading the objects with the code-derived layout, not the solver (2026-08-13)

unit_runtime.py places a field only if the disc values it somewhere — that is what lets it score (field, offset, encoding) triples — so it resolved 58 of 153 fields from a single-mission snapshot. The layout in data/unit_definition_layout.txt came from the title's own loader instead (sub_82341A20, where the field name of every store is a string in the image), so it places all 159, including the ones no disc record ever sets — which is precisely the Route-B target.

tools/re-capture/unit_dump_layout.py reads every field of every live definition object with that layout, and keeps the project's discipline: a field the disc does value is a check, not a new value. Over two snapshots (19 objects): 700 disc cross-checks agree, 0 disagree.

solver-derived layout-derived
fields placed per object 58 of 153 159
rows over these 19 units 609 2 736
defaulted-on-disc values, disc-wide file 1 059 2 351

Two things the run pinned down, both cheap to re-learn the hard way:

  • The layout table's offsets are DECIMAL (48 f32 Size_X), while the solver's CSV prints hex. Parsing it as hex puts every field 0x18 bytes late — and the disc cross-check then fails on everything, which is exactly how the mistake announced itself.
  • Angle fields can carry a prefix. AB_AA_PitchPlus (afterburner) is still an angle, so anchoring the AV_/AA_ test at the start of the name reported two player-craft fields as contradictions when they were 15°/16° in radians. With the token matched anywhere, the cross-check is clean.

⚠️ One unit's object is not byte-identical between the two missions — UN_f201_TCAF_Tanker. Either a per-mission override or a field the runtime mutates; the merged CSV holds the later reading, and separating them needs a third snapshot.

Targeting the next mission, and the check that these really are definitions (2026-08-13)

examples/roster_target.rs ranks the stages by how many of their roster units are not yet harvested. The rosters are EnumUnit_S<NN> tables whose TOC entries store a path hash, so the stage label comes from hashing the candidate paths (hash::TOC_NAME_SCHEMES) rather than from UnitRoster::stage, which can only infer a tag when the roster happens to carry a UN_S<NN>_… prop.

It picked S09 (10 missing); flying it took the file to 36 units / 4 785 rows / 3 439 defaulted-on-disc values, adding UN_e102_ADAN_Battleship, UN_e104_ADAN_Carrier, UN_e107_ADAN_AAFrigate, UN_e011_ADAN_Attacker_B, UN_e008_ADAN_TurretPlus, UN_be001_ADAN_TerrafoamingUnit, UN_e001_ADAN_Elan_GR{,_Violeta}, UN_f102_TCAF_LightCarrier_Inv and UN_f106_TCAF_Destroyer_Inv.

The objects are mission-independent — measured, not assumed. Eleven units appear in more than one snapshot, and four of them are not byte-identical across missions. Comparing them through the layout: zero mapped fields differ. The 12 differing 4-byte slots are all unmapped — offsets 4/8/16/20 (the object header and name pointer) and 0x250/0x268/0x3000x308/0x3300x338 (sub-object pointers) — i.e. guest addresses, not data. So a value harvested in one mission is the unit's definition, not a per-mission tweak, and the earlier UN_f201_TCAF_Tanker flag resolves the same way.

Cross-checks against the disc over the three snapshots: 1 052 agree, 0 disagree.

One more angle field turned up the same way as the last: Through_AngleMaximum (object 1.0472 = 60° in radians) carries neither an AV_/AA_ token nor Bank, so the degrees↔radians rule covers anything with Angle in the name too.

Harvest state: 60 of 110 units, and where the remaining 50 live (2026-08-13)

Six targeted missions (S03, S06, S09, S13, S15, S16, plus the earlier Night Ravens run) take the file to 60 units · 8 241 rows · 6 071 defaulted-on-disc values, from 21 units / 963 values this morning. Cross-checks against the disc across every snapshot: agree, 0 disagree — the discipline never had to be relaxed.

Two results worth calling out:

  • UN_e901_ADAN_Boss and UN_e910_core_ADAN_GeneratorCore are harvested (HP 10 000, Size_X 500, Size_Radius 250, MaximumVelocity 450) — without the boss ever appearing on screen. Definitions are instantiated when the stage loads, so a unit only has to be in the mission's roster, not in view. That is a much weaker requirement than the capture work needs, and it is why the same stage that could not give the e901 geometry gave its stats immediately.
  • The extra stages are not reachable this way. Setting the save's stage field to 27 boots to the title and then the emulator exits during the load — likewise the other S24…S29 entries. Story stages 116 all load normally. So the field addresses the story campaign only, and the EX/challenge rosters need whatever menu path Challenge mode uses.

That bounds the rest of the work: of the units still unread, the roster ranking puts almost all of them in S24/S25/S27/S28/S29 (*_EX4, *_EX5, *EX variants, UN_f004_TCAF_DeltaSaber_A_Player).

S08 closed the story stagesUN_be005_ADAN_SpaceFortress and UN_mn500_ADAN_FloatingMine (plus six UN_S08_Asteroid_cmesh_* collision meshes), taking the file to 68 units · 9 393 rows · 7 115 defaulted-on-disc values. Every unit any story mission fields is now read.

Probe: Game Status = GAME_CLEAR does NOT unlock a Challenge entry. Setting GHAD +48 (and its header mirror +0x18) to 2 — the STATE_GAME_CLEAR end of the enum this doc names — leaves both menus exactly as they were: the title still offers NEW GAME / LOAD GAME / TUTORIAL / OPTIONS / EXTRAS, and EXTRAS still offers only MISSION SELECT / MOVIE THEATER / BACK. So the EX rosters are not gated on that field, and GP_CHALLENGE.pak's content is reached some other way (or not at all from these screens).