Files
Syplheed-Reborn/docs/re/structures/unit-struct-runtime.md
Claude (auto-RE) 2196ac112d re: unit definitions are parsed at stage load, not on wave spawn
Counted the definition objects at three points in Stage 02 -- just after
take-off, ~12 min into combat, and after GAME OVER: 14 objects, same 14 IDs
each time. Capturing a stage therefore costs one load and one snapshot; the
mission does not have to be played or survived.
2026-07-29 17:31:45 +00:00

11 KiB
Raw Blame History

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

Confidence: CONFIRMED for the 22 fields marked below (each binding is reproduced by 1018 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 .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; 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 (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

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.

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.