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

229 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../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 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 +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.