Craft stats were the last parked Route-B target: the Hangar exposes only a weight class, and in menus the flight-model object is not instantiated at all. It is instantiated in-mission, so this reads it there. The definition class is discovered rather than assumed (unit_discover.py): scanning for pointers to the disc ID strings and tallying the word behind each pointer site picks out vtable 0x820af844 -- one object per unit ID, name-record pointer at +0x04, string at +0x10, exactly the Weapon shape. The neighbouring class 0x820af030 is the spawned entity, not the definition; the two are told apart by one-object-per-ID and by 14/14 objects being byte-identical across two snapshots 12 minutes and one firefight apart. 22 fields bind with zero contradictions on >=3 distinct values. Beyond that, the Maneuver sub-record turns out to be laid out in schema declaration order, 4 bytes per field, base 0x9c with a two-slot gap after AA_Roll_Min -- 29 independently solved anchors fit two exact bases with no conflicts. That rule pins five fields NO unit table ever values (PitchDragFactor, RollDragFactor, DragFactorThreshold, ArterBurner_Acc, DecPitchFactor); PitchDrag == RollDrag == the disc's YawDrag on 18/18 units corroborates the interpolation. Angles are float32 radians at runtime and degrees on disc. Coverage is 18 of 110 units: unlike weapons, unit definitions are instantiated per stage, so it grows by visiting missions. The AI-behaviour tail of Maneuver is explicitly NOT resolved and marked tentative in the CSV. Captured from Xenia Canary (BASIC CONTROLS tutorial + Stage 02 from save 01).
10 KiB
Runtime Unit struct (craft / vessel definitions) — read from live guest memory
Confidence: ✅ CONFIRMED for the 22 fields marked ✅ below (each binding is reproduced by 10–18 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: the BASIC CONTROLS tutorial (4 units) and Stage 02 "Declaration of War" loaded from save slot 01 (14 units). 18 of the disc's 110 units.
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 instance — live state | 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 |
Only 0x820af844 is used. It is the runtime image of the .tbl, and its
objects are byte-identical across two snapshots taken ~12 minutes apart with
combat in between (14/14 objects, 0 differing bytes) — definition data, not
state.
One .tbl → one 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; 15 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 |
18 | 13 |
+0x038 |
f32 | Size_Z |
16 | 13 |
+0x040 |
f32 | Color_R |
18 | 5 |
+0x044 |
f32 | Color_G |
16 | 6 |
+0x048 |
f32 | Color_B |
14 | 5 |
+0x050 |
f32 | Size_Radius |
11 | 10 |
+0x054 |
f32 | HP |
17 | 10 |
+0x074 |
f32 | ResistanceToOptics |
10 | 3 |
+0x08c |
f32 | ScorePoint |
18 | 9 |
+0x0a0 |
f32 | MaximumVelocity |
16 | 8 |
+0x0a4 |
f32 | CruisingVelocity |
14 | 7 |
+0x0a8 |
f32 | Acceleration |
15 | 5 |
+0x0ac |
f32 | Deceleration |
14 | 5 |
+0x0b0 |
rad | AV_PitchPlus_Max |
15 | 7 |
+0x0f8 |
f32 | SideThrustVelocity_Max |
12 | 3 |
+0x238 |
f32 | MaxValue (Shield) |
12 | 6 |
+0x244 |
f32 | ChargeSpeed (Shield) |
11 | 6 |
+0x270 |
f32 | DestroyMotionTime |
16 | 7 |
+0x2a0 |
f32 | RadarRange |
15 | 8 |
+0x2a4 |
f32 | FCSRange |
10 | 6 |
+0x2b4 |
f32 | AttackVesselPoint |
11 | 8 |
+0x2bc |
f32 | DefencePoint |
10 | 7 |
Size_Y +0x034, MassScore +0x094, HQRatio +0x058, ShieldRatio +0x05c,
ThrusterRatio +0x060, ResistanceToShell +0x078,
ResistanceToExplosion +0x07c, ResistanceToPlayer +0x080,
ResistanceParalyze +0x084, BridgeCount +0x070 (i32),
MinimumVelocity +0x09c, 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 440 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 (MinimumVelocity … AA_Roll_Min) |
0x09c + 4·i |
21 / 21 |
21 … 32 (SideThrustVelocity_Max … AccPitchFactor) |
0x0a4 + 4·i |
8 / 8 |
One 4-byte slot per field, with a two-slot gap after AA_Roll_Min
(0x0f0–0x0f4, purpose unknown). 29 independently-derived anchors, zero
conflicts, across a 0x9c–0x125 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 +0x104 … ArterBurner_Vc +0x114, and
ReverseThrust_Vc +0x118 … ReverseThrust_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 == YawDragFactorholds 18/18 — andYawDragFactoris disc-supplied (3.0 for craft, 2.0 for warships), so two interpolated offsets reproduce a known number, per unit, every time.DecPitchFactor == AccPitchFactorholds 17/18. The exception isUN_e015_ADAN_Puppy(AccPitchFactor1,DecPitchFactor0.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
18 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). Each further mission or
tutorial adds its own units; unit_runtime.py unions any number of snapshots
and re-solves, and more units directly promote the 🟡 bindings to ✅ by adding
distinct values.
Stages captured so far: Ttrl (BASIC CONTROLS), Stage 02.