# Live unit definitions from a running Stage 02 (2026-08-12)

The Route-B question — what value does a field that the disc leaves **defaulted**
actually take at runtime — needs the game running. This is the first snapshot of
the parsed definition objects taken straight out of guest RAM, plus the recipe
that works in the box today, because getting there was most of the work.

## The recipe that works

Everything must happen inside **one blocking foreground call**. A process that
outlives the call that started it is reaped — `nohup`, a harness background job
and a `wait`-holding wrapper were all tried and all died within a minute of the
turn ending (see the session-lifetime notes).

```bash
tools/re-capture/launch_mission.sh fly     # title → LOAD → slot 01 → READY ROOM → TAKE OFF
python3 tools/re-capture/gmem.py find hex:820af844 400   # live definition objects
python3 tools/re-capture/gmem.py words <va> 96           # dump each one
```

Two environment facts that cost an hour each:

- **Audio must be `--audio --apu=sdl` with `SDL_AUDIODRIVER=dummy`.** There is no
  PulseAudio server in the box, so muting (`--apu=nop`, `run-canary`'s default)
  looks like the safe choice. It is not: the log then fills with
  `AudioSystem::RegisterClient: CreateDriver failed for index=0`, the guest never
  gets past the movie, and the window stays black for 8+ minutes. `nav_probe.sh`
  and `launch_mission.sh` already pass the right pair — do not "fix" them.
- **Boot is fast once the caches are warm.** The first boot took minutes; with
  the shader/code cache populated, `launch_mission.sh` reached the flight HUD in
  **25 s**, which is what makes a launch-and-dump fit in a single call.

`entities2.py`, `flight_probe.py` and the other analysis scripts **do not run
here** — no `numpy` (and no `PIL` for image work). `gmem.py` is pure stdlib and
works, so the pattern is: dump raw words to disc inside the call, analyse
afterwards.

## What came out

14 live definition objects (vtable `0x820af844`), 96 words each:
[`captures/stage02-live-unit-definitions.txt`](captures/stage02-live-unit-definitions.txt),
with the flight screenshot they were taken from.

| object | `+0x30` | `+0x34` | `+0x38` | `+0x54` |
|---|---|---|---|---|
| `0xbd3b6a00` | 2500 | 3100 | 2600 | 10000 |
| `0xbd3d2d80` | 80 | 80 | 350 | 4000 |
| `0xbd3da100` | 10 | 7 | 29 | 1500 |
| `0xbd3db980` | 350 | 70 | 200 | 10000 |
| `0xbd3dbd00` | 10 | 5 | 20 | 300 |
| `0xbd3dea80` | 20 | 9 | 22 | 100 |
| `0xbd3dfc00` | 10 | 7 | 29 | 1000 |
| `0xbd3e0680` | 300 | 300 | 2800 | 3000 |
| `0xbd3e1f00` | 400 | 400 | 1400 | 25000 |
| `0xbd3e6f80` | 300 | 300 | 2100 | 10000 |
| `0xbd3ee300` | 200 | 200 | 2000 | 10000 |
| `0xbd3faa80` | 100 | 40 | 50 | 500 |
| `0xbd3fd800` | 600 | 600 | 3800 | 30000 |
| `0xbd40e200` | 700 | 700 | 3800 | 30000 |

This **corroborates** the layout solved earlier from a single in-mission
snapshot (`+0x054` = HP, `+0x30…0x38` = `Size_X/Y/Z`) rather than re-proving it:
the `+0x54` column reads as hull values across three orders of magnitude
(100 for something small, 30 000 twice for capital ships), and the sizes scale
with them. It also gives independent support to the **`Size_Y` inherits `Size_X`**
default rule — five of the fourteen have `+0x30 == +0x34` exactly
(300/300, 400/400, 200/200, 600/600, 700/700) while the rest differ.

## Identity without a name: match on the values

Nothing in the first 96 words is a name pointer, so identity comes from the
numbers themselves. `examples/unit_signatures.rs` prints `(HP, Size_X, Size_Y,
Size_Z)` for every craft unit and vessel on the disc; a live object is then the
record whose *known* fields it reproduces exactly.

Seven of the fourteen match a disc record on all four values — the player's
Delta Saber (`10, 7, 29`, HP 1500 in its player form and 1000 otherwise), the
ArrowHead, an ADAN turret, an Attacker. **The other seven match nothing, and
that is the point:** their disc records leave a field defaulted, so there is
nothing to match against. **18 of the 23 vessel records on disc are missing at
least one of these four fields**, nearly always `Size_Y`.

## ✅ Route B: 13 defaulted fields read out of the running game

Matching each disc record that defaults exactly one field against the live object
that reproduces its remaining fields resolves the missing value:

| unit | field | runtime value | live object (sx, sy, sz, hp) |
|---|---|---|---|
| `UN_e201_ADAN_ISCMissile` | `Size_Y` | **300** | `0xbd3e0680` (300, 300, 2800, 3000) |
| `UN_f106_TCAF_Destroyer` | `Size_Y` | **200** | `0xbd3ee300` (200, 200, 2000, 10000) |
| `UN_f106_TCAF_Destroyer_Inv` | `Size_Y` | **200** | `0xbd3ee300` |
| `UN_e105_ADAN_Cruiser` | `Size_Y` | **600** | `0xbd3fd800` (600, 600, 3800, 30000) |
| `UN_e105_ADAN_CruiserEX` | `Size_Y` | **600** | `0xbd3fd800` |
| `UN_f105_TCAF_Cruiser` | `Size_Y` | **700** | `0xbd40e200` (700, 700, 3800, 30000) |
| `UN_f105_TCAF_Cruiser_EX5` | `Size_Y` | **700** | `0xbd40e200` |
| `UN_f105_TCAF_Cruiser_Inv` | `Size_Y` | **700** | `0xbd40e200` |
| `UN_e108_ADAN_ASFrigate` | `Size_Y` | **80** | `0xbd3d2d80` (80, 80, 350, 4000) |
| `UN_e108_ADAN_ASFrigateEX` | `Size_Y` | **80** | `0xbd3d2d80` |
| `UN_e106_ADAN_Destroyer` | `Size_Y` | **300** | `0xbd3e6f80` (300, 300, 2100, 10000) |
| `UN_e106_ADAN_DestroyerEX` | `Size_Y` | **300** | `0xbd3e6f80` |
| `UN_f101_TCAF_Acropolis` | `Size_Y` | **400** | `0xbd3e1f00` (400, 400, 1400, 25000) |

**Every resolved value equals that unit's `Size_X`** — so the running game
confirms the `Size_Y` inherits `Size_X` rule that was derived statically from
sibling records, this time from the engine's own parsed definitions.

Two honest limits. Variants share a live object (`_Inv`, `_EX5`, `EX` map to the
same definition as their base), so the snapshot cannot distinguish them — the
value is right for the class, and per-variant differences in *other* fields are
not addressed. And this run only reaches the units Stage 02 instantiates: the
`…Ratio` / `…Count` family (`HQRatio`, `PowerRatio`, `ShieldRatio`, `HatchCount`,
`NodeCount`, `SequencingCount`, …) is defaulted on 5–23 records each and is
**not** resolved here, because those fields' offsets in the live object are not
yet known. Finding them is the next run: dump deeper than 96 words and look for
the constants the disc *does* set on the records that set them.

## Locating fields inside the live object

The next step — reading the `…Ratio` / `…Count` family — needs to know *where*
each field sits in the live object. The method is to anchor on a unit whose disc
record **sets** a field and look for that value in its dumped words. Anchoring on
`UN_f106_TCAF_Destroyer` (disc sets `Size_Radius 0.1`, `ThrusterRatio 0.8`,
`ResistanceParalyze 0.97`) and then checking the same offsets across five capital
ships and the player's fighter:

| offset | reading | `f106` | `f105` | `e105` | `e106` | `f101` | Delta Saber |
|---|---|---|---|---|---|---|---|
| `+0x30` | `Size_X` ✅ | 200 | 700 | 600 | 300 | 400 | 10 |
| `+0x34` | `Size_Y` ✅ | 200 | 700 | 600 | 300 | 400 | 7 |
| `+0x38` | `Size_Z` ✅ | 2000 | 3800 | 3800 | 2100 | 1400 | 29 |
| `+0x54` | `HP` ✅ | 10000 | 30000 | 30000 | 10000 | 25000 | 1500 |
| `+0x74` | 🟡 `ThrusterRatio` | 0.8 | 0.8 | 0.8 | 0.8 | 0.8 | 1 |
| `+0x84` | 🟡 `ResistanceParalyze` | 0.97 | 0.97 | 0.97 | 0.97 | 0.97 | 0.97 |
| `+0x40` | ❔ | 0.1 | 0.1 | 1 | 1 | 1 | 0.1 |

`+0x74` and `+0x84` are marked 🟡 because they rest on one anchoring unit: the
value is right for the Destroyer and constant across the others, which is
consistent with a field the rest default, but a second anchoring unit that sets
them to something *different* is what would prove it. `+0x40` is 0.1 on two units
and 1 on three, so it is a real per-unit field — just not identified.

⚠️ **The automated version finds almost nothing, and the reason is not what it
first looked like.** `examples/live_offsets.rs` correlates every `get_f32(key)`
against every dumped word and keeps offsets all units agree on. Over five units
it produced exactly **one** hit, `YawDragFactor → +0x0c`, and that hit is
**wrong**: `+0x0c` holds the integer 2 (printed as the denormal `2.8e-45`), which
collided with `YawDragFactor = 2.0`. Any float-compare against small integers has
that failure mode.

The first explanation written here — that `get_f32` is unreliable on
default-heavy records — was **wrong**, and measuring killed it: `get_f32`
resolves **46–57 numeric fields per unit** (Destroyer 46, `e105` Cruiser 57,
`f105` Cruiser 49, `e106` Destroyer 51, Acropolis 45). It is the documented,
reliable API and it works.

The real problem is that **the values are not in this object**. Dumping 512 words
(2 KB) instead of 96 changes nothing at all: still exactly **4 of ~50** disc
values found, for every one of the five units. So the structure at vtable
`0x820af844` is not the parsed definition record — it carries the handful of
fields above (sizes, HP, a couple of ratios) and the rest of the record lives
elsewhere, presumably behind a pointer.

Next probe, therefore, is not a bigger window: search guest RAM for a value that
is distinctive to one unit (e.g. the Acropolis' `HP 25000` = `0x46c35000`) and see
what *other* structure holds it, then map that one.

## Where the rest of the record is — three probes, and a hypothesis

Following "search RAM for a unit-distinctive value" produced no parsed record:

1. **`HP 25000`** (`0x46c35000`, the Acropolis) — **200+ hits**, the search limit.
   Too common to anchor on: it is also a generic constant.
2. **`AttackVesselPoint 0.08`** (`0x3da3d70a`, an unusual float) — 64+ hits, and
   **none of the eight windows dumped around them contained the unit's other
   values** (`Size_X 400`, `Size_Z 1400`, `HP 25000`, `RadarRange 60000`). One hit
   sits in the executable range (`0x820b62f4`), i.e. a code/rdata constant.
3. **The ID string itself.** `UN_f101_TCAF_Acropolis` appears **twice** in RAM,
   each copy referenced by a pointer exactly **16 bytes before the string**. But a
   256-word window around it contains **none** of the unit's numbers — because
   that region is the IDXD **string pool**, where `25000.0` is stored as *text*.

🟡 **Hypothesis: there is no flat parsed record.** IDXD is a reflective format,
and the evidence fits the engine reading values out of the pool by key when it
needs them, caching only the few it touches per frame. That is exactly what the
`0x820af844` object looks like: sizes (collision), HP (damage), a couple of
ratios — and nothing else, no matter how deep it is dumped.

If that is right, it reframes Route B. For a **defaulted** field there is no value
in the pool at all, so the default is applied by *code* at read time and can never
be found by scanning data structures. It would have to come from either the read
path (trace where a key hash is looked up and what the miss path substitutes) or
from the title's own code — which is the DuckDB static route this project already
has. The 13 `Size_Y` values recovered above worked precisely because `Size_Y` is
one of the cached, every-frame fields.

This is a hypothesis, not a finding: it explains three negative probes but has not
been confirmed by watching a read happen.

## The static route says where the defaults must be

The DuckDB image (`xenia-rs/sylpheed.db`, 12 156 functions / 1.87 M instructions)
has the reflective machinery under real names:

| address | name |
|---|---|
| `0x824486c0` | `IdxdLoad_Dispatch` |
| `0x82448d00` | `IdxdLoad_Variant2` |
| `0x82449640` | `Idxd_Parse` |
| `0x8244a2f0` | `Reflect_FindFieldIndex` |
| `0x8244a4b0` | `Reflect_SetField` |

`Reflect_FindFieldIndex` measures a C string, indexes a buffer and calls a
compare routine — it looks a field up **by name at runtime**, which is the
reflective read the hypothesis above predicted. But `Idxd_Parse` is a **text
parser** (it checks for a UTF-16 BOM, walks characters through a jump table at
`0x824497f8`) and it calls `Reflect_SetField` on a target object passed in `r3`.

That last point **weakens the "no parsed record" hypothesis**: parsing writes
values into a real object. So a parsed record does exist — my three RAM probes
simply did not find the right one — and, more usefully, it tells us where a
*defaulted* field's value comes from. `Idxd_Parse` only ever writes the fields the
text actually contains, so a defaulted field keeps **whatever the object had
before parsing** — i.e. the value its **constructor** stored.

So the defaults are neither in the disc data (by definition) nor in a table to be
scanned for: they are immediates in the constructor of each definition class.
Reading them is a disassembly job — find the allocation site above
`IdxdLoad_Dispatch`, then the constructor it calls, and read the stores. That is
the next step, and it would settle the whole `…Ratio` / `…Count` family at once
rather than one field per flight.

(The `vtables`, `classes` and `methods` tables are empty in this DB build, as
`CLAUDE.md` warns, so the class has to be reached through the call graph.)

## The constructor zero-fills, so the defaults are derived, not stored

Walking the call graph found the class: `sub_8233FAF8` (`0x8233faf8`) writes the
vtable **`0x820af844`** — the very objects dumped above — into `0(r30)` and then
initialises the instance. Every one of its 32 stores writes **zero**
(`stw r29, …` with `r29 = 0`), out to `+872` and beyond, interleaved with
string-member constructor calls.

That kills the natural next hypothesis: **the defaults are not immediates in the
constructor.** The object starts at zero, `Idxd_Parse` writes only the fields the
text contains, and yet a defaulted `Size_Y` reads back as a *non-zero* value at
runtime (200 for the Destroyer, 700 for the `f105` Cruiser — always `Size_X`).

So a defaulted value must be **derived after parsing**, which is exactly the shape
of the `Size_Y` inherits `Size_X` rule this project derived statically from sibling
records. Finding that fixup pass is what would yield the rest of the family.

Two candidate sites turned up by searching for a load of `+0x30` feeding a store
to `+0x34` — `0x82217e18` (in `sub_82217A90`) and `0x823081e8` — but neither is
confirmed: the first is a *conditional* assignment (it compares `+0x30` against a
register and picks between two values before storing to `+0x34`), not a plain
copy, and offsets 48/52 are generic enough that both could belong to unrelated
structures. Recorded as leads, not findings.

## The filler, and a constant that turned out not to be a default

The call graph completes: `sub_82341048` allocates **880 bytes**
(`addi r3, r0, 880` → `bl 0x8230C160`), calls the zero-filling constructor
`sub_8233FAF8`, and then hands the object to **`sub_82341A20`** — 15 876 bytes,
**3 969 instructions, 157 `stfs`, 892 calls**. That is the per-field filler for a
unit definition, and it is where any default has to be applied.

Inside it, one pattern repeats: `lfs f, -15176(r30)` followed by `stfs f, +N(r29)`
for N = 336, 340, 344, 348, 352, 356, 360, 428, 512, 516 (and 124…160 of a
sub-object). `r30` resolves to `0x82090000 − 28780 = 0x82088f94` (from
`addis r11, r0, 0x8209` / `addi r30, r11, -28780`), so the constant sits at
**`0x820854cc`**, which the executable image gives as **`0x3f800000` = 1.0**.

**That looks exactly like "write the default 1.0 into ten fields" — and it is
wrong.** Checking those offsets in the live objects first:

| offset | `f001` fighter | `e007` turret | most others |
|---|---|---|---|
| `+336` | 0.261799 (15°) | 0 | 0 |
| `+344` | 0.174533 (10°) | 0 | 0 |
| `+356` | 0.698132 (40°) | 0 | 0 |
| `+428` | 0 | 0.785398 (45°) | 0 |
| `+512` | 0 | 0.523599 (30°) | 0 |

Those are **radians** — 10°, 15°, 16°, 30°, 40°, 45°, 60° — i.e. per-unit angle
limits converted from the degrees the disc stores (`MaximumBank_Normal 60` →
1.0472). So the `1.0` is part of a formula or a conditional path in that
conversion, **not** a blanket default, and the fields themselves are 0 for units
whose records omit them.

Which leaves the picture consistent with everything measured so far: **the ordinary
default is 0**, straight from the zero-filling constructor, and the interesting
non-zero defaults (`Size_Y` ← `Size_X`) are specific derivations to be found
individually in this filler. Worth noting for the reimplementation regardless:
**angle fields are stored in radians at runtime and in degrees on disc.**

## Confirming the field really is defaulted, and where the loader leaves it

Before chasing the derivation any further it was worth checking the premise.
`examples/pool_window.rs` prints the raw token sequence around a key, and for the
Destroyer it reads:

```
[14] "200.0"   [15] "Size_X"   [16] "Size_Y"   [17] "2000.0"   [18] "Size_Z"
```

`Size_X` takes the value before it; **`Size_Y` is a bare key** — genuinely
defaulted, exactly as the value-before-key rule predicts. So the 13 values
recovered from the running game are real defaults, not a reader artefact.

The loader's own answer for a missing field is now pinned too. Each size field is
filled by the same three-call sequence — build the key string, call the float
accessor **`sub_822FC5A8`**, store the result:

```
0x82341d94  addi r4, r30, -14004     ; "Size_Y"
0x82341da0  bl   0x8217FA08          ; make key
0x82341dac  bl   0x822FC5A8          ; read float
0x82341db0  stfs f1, 52(r29)         ; -> Size_Y
```

and `sub_822FC5A8` loads `f31` from `0x8209fd28` = **0.0** at entry and returns it
when the pool lookup misses. **So the definition leaves a defaulted `Size_Y` at
0.0**, and nothing else in the 15 876-byte filler writes `+52`.

Two readings corrected on the way:

- The block at `0x82341e4c`–`0x82341ebc` that loads `+48`/`+52`/`+56` is **not** a
  "was this field set" mask. It compares each against the constant at
  `0x8209fd20` = **1000.0** and ORs flags into `+108` — a **size-class bitmask**
  for large objects.
- Both earlier `Size_Y` candidates are refuted: `0x82217e18` conditionally picks
  between two registers rather than copying, and `0x823081e8` *multiplies* `+48`
  by a table constant into `+52` of a different structure.

So the runtime `Size_Y = Size_X` must be written **after** the definition is
loaded — by whatever instantiates a unit from it, or a post-load pass over the
table. `+108` itself is too generic to chase (1 129 loads image-wide).

## ✅ The field map, read out of the code — and the Ratio family with it

The filler builds each key as `addi r4, r30, -N` with `r30 = 0x82088f94`, so the
**field name for every store is a string in the image**. Pairing each key with the
`stfs` that follows gives the object layout directly, with no guessing:

| offset | field | offset | field |
|---|---|---|---|
| `+48` | `Size_X` | `+116` | `ResistanceToOptics` |
| `+52` | `Size_Y` | `+120` | `ResistanceToShell` |
| `+56` | `Size_Z` | `+124` | `ResistanceToExplosion` |
| `+64/68/72` | `Color_R/G/B` | `+128` | `ResistanceToPlayer` |
| `+80` | `Size_Radius` | `+132` | `ResistanceParalyze` |
| **`+88`** | **`HQRatio`** | `+672` | `RadarRange` |
| **`+92`** | **`ShieldRatio`** | `+676` | `FCSRange` |
| **`+96`** | **`ThrusterRatio`** | `+680` | `FiringRange` |
| `+692` | `AttackVesselPoint` | `+696` | `AttackCraftPoint` |
| `+700` | `DefencePoint` | | |

`examples/verify_fieldmap.rs` checks it against the live dump: **62 fields agree
with the disc record, 0 disagree** across five capital ships.

That turns the same dump into the answer for the family the brief asked about —
the runtime value of each field **for the units whose record omits it**:

| field | runtime value(s) seen | units |
|---|---|---|
| `Size_Y` | 200, 300, 400, 600, 700 (each `= Size_X`) | 5 |
| `HQRatio` | **0.2**, **1** | 4 |
| `ThrusterRatio` | **0.2**, **1** | 4 |
| `ShieldRatio` | **1** | 1 |
| `ResistanceToPlayer` | **1** | 5 |
| `ResistanceToShell` | **0.1** | 3 |
| `ResistanceToExplosion` | **0.5** | 3 |
| `AttackVesselPoint` | **0.1** | 2 |
| `AttackCraftPoint` | **0.1**, **0.5** | 5 |
| `DefencePoint` | **0.1**, **0.25** | 4 |
| `FiringRange` | **0** | 5 |

⚠️ Read that table as *"what the field holds at runtime for a unit that does not
set it"*, **not** as "the default constant". Several fields show **two distinct
values** across units (`HQRatio` 0.2 or 1, `AttackCraftPoint` 0.1 or 0.5), so the
value is derived per unit rather than being one global default — consistent with
`Size_Y`, which takes each unit's own `Size_X`. Only `FiringRange` (0 everywhere)
matches the loader's own miss value.

*Method note:* the first run of this check reported 32 "mismatches". They were an
off-by-one column in the dump reader — `f[3]` is the u32, `f[4]` the float — and
decoding one by hand (`1128792000` = `0x43480000` = 200.0) showed the map had been
right all along.

## ✅ The whole map verified — 154/154 — and 101 defaulted fields read out

Anchoring the key string to the **first store after the accessor call** (rather
than to the nearest store) makes the extraction exact: **159 fields**, of which
**125 are floats**. `verify_fieldmap.rs` now checks every one of them against the
live objects: **154 field values agree with the disc record, 0 disagree** across
five capital ships.

Getting there needed one rule, and the ten last "mismatches" are what taught it:

```
AV_PitchPlus_Max    disc 3.6   live +176 = 0.0628319   ( = 3.6° in radians)
MaximumBank_Normal  disc 60    live +256 = 1.0472      ( = 60°  in radians)
```

**Angle fields are degrees on disc and radians at runtime** — the incidental
observation from the previous pass, now confirmed as an exact conversion across
every angle field and every unit.

With the map verified, the dump answers the brief's question for **101 fields**
that these records leave defaulted
([`captures/stage02-defaulted-field-runtime-values.txt`](captures/stage02-defaulted-field-runtime-values.txt)).
The shape of the answer:

- **Angle limits default to 3.6°** (`0.0628319` rad) — every `AA_*`/`AV_*`
  `Pitch/Roll/Yaw` `Min`/`Max`, identical across all five units.
- **Afterburner and turn-attack fields default to 0** (`AB_*`, `TurnAttack_*`,
  `AA_AxisMode_*`, `AV_AxisMode_*`).
- **`FiringRange` defaults to 0**, matching the loader's own miss value exactly.
- **The ratio family is per-unit, not constant**: `HQRatio` 0.2 or 1,
  `ThrusterRatio` 0.2 or 1, `AttackCraftPoint` 0.1 or 0.5, `DefencePoint` 0.1 or
  0.25, `DelayAdjustment` 0 or 2 — so these are *runtime values for units that do
  not set the field*, not a single global default.
- **`Size_Y` takes that unit's own `Size_X`** (200/300/400/600/700).

That is the `…Ratio` / `…Count` family the mission brief named, answered from the
running game, with the field identities read out of the title's own code rather
than guessed.

## It generalises, it disambiguates, and variants are provably identical

Applying the same map to the other live objects — turrets, fighters, an attacker,
a station, i.e. `UNIT` records rather than `VESSEL` ones — gives **406 field
values agreeing with the disc and 0 disagreeing across 11 objects**. One object
layout serves both schemas.

Two things fell out that the four-value signature could never have given:

**It identifies the object.** `0xbd3b6a00` had two candidates on
`(HP, Size_X/Y/Z)`. Checking every mapped field separates them cleanly:

| candidate | agreement |
|---|---|
| `UN_be005_ADAN_SpaceFortress` | 12 agree, **2 disagree** (`Color_R` 1↔0, `Color_G` 0↔1) |
| **`UN_bf001_TCAF_SchlosBase`** | **14 agree, 0 disagree** |

**Variants really are identical.** Where several ids shared a signature, all of
them now agree on *every* mapped field — `UN_f003_TCAF_ArrowHead` / `_EX4` /
`_EX5` at 48/48 each, `UN_e007_ADAN_Turret` / `_EX4` at 51/51, and three Delta
Saber player variants at 49/49 (the tutorial variant `_Ttrl1` differs by one field
and is excluded). So a shared live definition between variants is a property of
the data, not a limitation of the match.

Across all 11 objects, **121 fields** are defaulted on disc and readable at
runtime. `FiringRange` (0 in all 11) and `ShieldRatio` (1 in all 6 that default
it) look like genuine constants; `HQRatio` (0.2 or 1 over 9 units),
`ThrusterRatio` (0.2 or 1 over 10) and `AttackCraftPoint` (0, 0.1, 0.5 or 1 over
9) are clearly per-unit and must not be recorded as one value.

## Landed in the crate

The map is no longer only an analysis artefact:

- **`data/unit_definition_layout.txt`** — the 159 fields (offset, kind, name),
  with the provenance and the two conventions (degrees→radians; a defaulted field
  keeps the accessor's miss value) in its header.
- **`sylpheed_formats::unit_layout`** — `fields()`, `field_at(offset)`,
  `offset_of(name)`, so a memory snapshot can be read by name.
- **`tests/unit_layout_disc.rs`** — replays the whole check against the
  checked-in dump: every mapped float of all 11 identified objects must equal its
  disc value (angles compared in radians). Asserts 0 disagreements and ≥400
  agreements, and needs **no emulator** — the capture is in the repo.

Full suite green: 11 test binaries, workspace builds.
