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.
229 lines
11 KiB
Markdown
229 lines
11 KiB
Markdown
# 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](../weapon-datasheet-runtime.md) 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`](../../../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`](../../../tools/re-capture/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.
|
||
|
||
```bash
|
||
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`](../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`](../../../tools/re-capture/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 == 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.
|