unit-struct-runtime.md lists ~30 player-craft fields as defaulted on disc with values "recovered from the running game". All of them are on the disc at exactly those values. Verified myself for UN_f001_TCAF_DeltaSaber_T: Size_Radius 10.0, FCSRange 500000.0, ResistanceToOptics/HQRatio/ ShieldRatio/ThrusterRatio/ResistanceToShell/ResistanceToExplosion 1.0, MassScore 0 [Generic] ChargeSpeed 25.0, ChargeDelay_Break 10.0 [Shield] DryMass 100.0 [Mass] LowerHPThresholdRatio 0.3 [SE] The record column is the explanation: the values are spread across Generic, Shield, Mass and SE. That is exactly why the old flat reader missed them -- it could not say which record it meant, so a per-record field read as absent. Also wrong, and measured: "18 of the 23 vessel records are missing at least one of Size_X/Y/Z/HP, nearly always Size_Y". Over every object carrying a Generic.Type -- 43 Craft plus 71 Vessel, 114 objects -- ZERO are missing any of the four. live-unit-definitions.md's "Route B: 13 defaulted fields read out of the running game" goes with it: all 13 Size_Y values are on disc, each exactly equal to the value the runtime run "recovered". Nothing was recovered that the disc did not hold; the run confirms only that the parser reads the disc. As with the weapon sheet, the runtime captures stand as measurements. What is withdrawn is the premise that they were reaching values the disc lacks. Correction banners at the top of both; original text intact beneath. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
603 lines
32 KiB
Markdown
603 lines
32 KiB
Markdown
# Runtime `Unit` struct (craft / vessel definitions) — read from live guest memory
|
||
|
||
> ## ❌ CORRECTION (2026-08-26) — "defaulted on disc" is false here too
|
||
>
|
||
> The player-craft table below lists ~30 fields as *defaulted on disc* and gives
|
||
> values "recovered from the running game". **All of them are on the disc**, at
|
||
> exactly those values — the [record table](idxd-container.md) reads them
|
||
> directly. Verified for `UN_f001_TCAF_DeltaSaber_T`:
|
||
>
|
||
> | field | record | on disc |
|
||
> |---|---|---|
|
||
> | `Size_Radius` | `Generic` | 10.0 |
|
||
> | `FCSRange` | `Generic` | 500000.0 |
|
||
> | `ResistanceToOptics` / `HQRatio` / `ShieldRatio` / `ThrusterRatio` | `Generic` | 1.0 |
|
||
> | `ResistanceToShell` / `ResistanceToExplosion` | `Generic` | 1.0 |
|
||
> | `MassScore` | `Generic` | 0 |
|
||
> | `ChargeSpeed` / `ChargeDelay_Break` | **`Shield`** | 25.0 / 10.0 |
|
||
> | `DryMass` | **`Mass`** | 100.0 |
|
||
> | `LowerHPThresholdRatio` | **`SE`** | 0.3 |
|
||
>
|
||
> Look at the record column: the values are spread across `Generic`, `Shield`,
|
||
> `Mass` and `SE`. That is *why* the old flat reader missed them — it had no way
|
||
> to say which record it meant, so a per-record field read as absent.
|
||
>
|
||
> ❌ **"18 of the 23 vessel records are missing at least one of `Size_X/Y/Z/HP`,
|
||
> nearly always `Size_Y`"** is also wrong. Measured over every object with a
|
||
> `Generic.Type`: **43 Craft + 71 Vessel = 114 objects, and 0 of them are missing
|
||
> any of the four.**
|
||
>
|
||
> The runtime capture stands as measurement; what is withdrawn is the premise
|
||
> that it was reaching values the disc does not hold. See also
|
||
> [idxd-legacy-reader-audit](../idxd-legacy-reader-audit.md).
|
||
|
||
|
||
**Confidence: ✅ CONFIRMED** for the 27 fields marked ✅ below (each binding is
|
||
reproduced by 10–21 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: **all six tutorials** and **Stage 02 "Declaration of War"** loaded
|
||
from save slot 01. 21 of the disc's 110 units, over 7 snapshots from 7 separate
|
||
emulator runs.
|
||
|
||
## 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 record** — one per spawned thing, but **not live state** (see below) | 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` |
|
||
|
||
**Correction (2026-07-29, from the autopilot work):** `0x820af030` was
|
||
described here as holding live state. It does not — across a 29 s in-flight
|
||
capture **all 384 words of it are constant**. It is one record per spawned
|
||
thing, but the flying entity's transform is somewhere else entirely. See
|
||
[autopilot-memory-driven](../autopilot-memory-driven.md). Nothing below depends
|
||
on it; the definition class `0x820af844` is unaffected.
|
||
|
||
Only `0x820af844` is used. It is the runtime image of the `.tbl`:
|
||
|
||
* **Within one run** it is byte-identical across two snapshots taken ~12 minutes
|
||
apart with combat in between (14/14 objects, 0 differing bytes) — definition
|
||
data, not live state.
|
||
* **Across runs** the same unit is *not* byte-identical, and that had to be
|
||
explained rather than waved away. `unit_runtime.py --crosscheck` compares
|
||
every unit that appears in more than one snapshot (5 of them, over 7 runs):
|
||
exactly **15 words differ**, and 13 of them hold guest pointers
|
||
(`0x8xxxxxxx`/`0xbxxxxxxx` — heap addresses, which move per process).
|
||
**No solved or interpolated field offset is among the 15** — every value
|
||
reported here is run-invariant.
|
||
* The two non-pointer stragglers, `+0x2c8` and `+0x2d0`, are **stage-dependent**:
|
||
for one and the same unit (`UN_f001_TCAF_DeltaSaber_T_Ttrl`) `+0x2c8` reads
|
||
`8000.0` in two tutorials and `10000.0` in a third, with `+0x2d0` a 0/1 flag
|
||
beside it. So the object is *mostly* but not *entirely* the parsed table —
|
||
a couple of words are set per stage. Unidentified; **NEEDS-HUMAN**.
|
||
|
||
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; 18 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` | 21 | 13 |
|
||
| `+0x034` | f32 | `Size_Y` | 12 | 7 |
|
||
| `+0x038` | f32 | `Size_Z` | 19 | 13 |
|
||
| `+0x040` | f32 | `Color_R` | 21 | 5 |
|
||
| `+0x044` | f32 | `Color_G` | 19 | 6 |
|
||
| `+0x048` | f32 | `Color_B` | 17 | 5 |
|
||
| `+0x050` | f32 | `Size_Radius` | 12 | 10 |
|
||
| `+0x054` | f32 | `HP` | 19 | 10 |
|
||
| `+0x074` | f32 | `ResistanceToOptics` | 11 | 3 |
|
||
| `+0x08c` | f32 | `ScorePoint` | 21 | 9 |
|
||
| `+0x094` | f32 | `MassScore` | 10 | 7 |
|
||
| `+0x09c` | f32 | `MinimumVelocity` | 11 | 3 |
|
||
| `+0x0a0` | f32 | `MaximumVelocity` | 19 | 8 |
|
||
| `+0x0a4` | f32 | `CruisingVelocity` | 17 | 7 |
|
||
| `+0x0a8` | f32 | `Acceleration` | 18 | 5 |
|
||
| `+0x0ac` | f32 | `Deceleration` | 17 | 5 |
|
||
| `+0x0b0` | rad | `AV_PitchPlus_Max` | 18 | 7 |
|
||
| `+0x0b4` | rad | `AV_PitchPlus_Min` | 10 | 6 |
|
||
| `+0x0c4` | rad | `AV_PitchMinus_Min` | 10 | 5 |
|
||
| `+0x0f8` | f32 | `SideThrustVelocity_Max` | 14 | 3 |
|
||
| `+0x238` | f32 | `MaxValue` (Shield) | 13 | 6 |
|
||
| `+0x244` | f32 | `ChargeSpeed` (Shield) | 13 | 6 |
|
||
| `+0x270` | f32 | `DestroyMotionTime` | 19 | 7 |
|
||
| `+0x2a0` | f32 | `RadarRange` | 18 | 9 |
|
||
| `+0x2a4` | f32 | `FCSRange` | 12 | 8 |
|
||
| `+0x2b4` | f32 | `AttackVesselPoint` | 14 | 8 |
|
||
| `+0x2bc` | f32 | `DefencePoint` | 12 | 7 |
|
||
|
||
`HQRatio +0x058`, `ShieldRatio +0x05c`,
|
||
`ThrusterRatio +0x060`, `ResistanceToShell +0x078`,
|
||
`ResistanceToExplosion +0x07c`, `ResistanceToPlayer +0x080`,
|
||
`ResistanceParalyze +0x084`, `BridgeCount +0x070` (i32),
|
||
`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 827 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
|
||
|
||
21 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). `unit_runtime.py` unions
|
||
any number of snapshots and re-solves, and more units directly promote the 🟡
|
||
bindings to ✅ by adding distinct values — the six tutorials took the confirmed
|
||
set from 22 fields to 27.
|
||
|
||
The tutorials are nearly exhausted as a source: all six together contribute only
|
||
3 units the missions do not already have (`UN_f001_TCAF_DeltaSaber_T_Ttrl`,
|
||
`UN_e015_ADAN_Puppy_2`, `UN_f001_TCAF_DeltaSaber_T_Player_Ttrl2`) — they reuse
|
||
one training box, one target drone and the player craft. **Real coverage now
|
||
needs real missions**, i.e. story progress on the save.
|
||
|
||
`tools/re-capture/grab_tutorial.sh` captures one tutorial per invocation
|
||
(cold boot → menu → Nth entry → snapshot, ~2.5 min). It cold-boots for each
|
||
because backing out of a loaded mission via PAUSE → BACK TO MENU wedges the
|
||
emulator. Two timing facts it encodes, both learned the hard way: the main menu
|
||
is **not input-ready for ~10 s** after the title tap, and d-pad presses before
|
||
that are silently dropped — which sends the A to `NEW GAME` instead of
|
||
`TUTORIAL`. And `NEW GAME` is not a cheap way to reach Stage 01: it gates on a
|
||
DIFFICULTY menu and then plays the prologue movie.
|
||
|
||
A NEW GAME excursion as far as the READY ROOM leaves
|
||
`535107D4/00000001/game01/savedata` byte-identical — only the profile `.gpd`
|
||
achievement files change — so it does not endanger the 5 % save. Verified by
|
||
diff against a backup, not assumed.
|
||
|
||
**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.
|
||
|
||
## ❌ WITHDRAWN — "some defaulted fields inherit from a sibling"
|
||
|
||
**The rules below are a string-pool DEDUPLICATION ARTEFACT. The fields were never
|
||
defaulted: they are on disc for 113–114 of 114 unit tables.** Measured
|
||
2026-08-25 against the [record table](idxd-container.md), which the old reader
|
||
could not see; artifact `examples/sibling_rule_check.rs`.
|
||
|
||
The premise was that these fields are absent from the disc. They are not:
|
||
|
||
| field | present on disc | **differs** from its claimed parent |
|
||
|---|---|---|
|
||
| `Size_Y` vs `Size_X` | **114 / 114** | **90** |
|
||
| `FCSRange` vs `RadarRange` | 113 / 113 | 55 |
|
||
| `DefencePoint` vs `AttackVesselPoint` | 113 / 113 | 52 |
|
||
|
||
So `Size_Y` does not "inherit" `Size_X` — it differs from it in 90 of 114 units.
|
||
|
||
### Why the rules nevertheless *worked*
|
||
|
||
The old reader infers a value from string-pool adjacency, and the pool stores
|
||
each distinct string **once**. So a field whose value equals a sibling's
|
||
contributes no token of its own and reads as absent. Cross-tabulating "reader
|
||
missed it" against "equal on disc" makes the mechanism exact:
|
||
|
||
| pair | seen + differ | seen + equal | **missed + differ** | missed + equal |
|
||
|---|---|---|---|---|
|
||
| `Size_Y` / `Size_X` | 90 | **0** | **0** | 24 |
|
||
| `FCSRange` / `RadarRange` | 54 | **0** | 1 | 58 |
|
||
| `DefencePoint` / `AttackVesselPoint` | 51 | **0** | 1 | 61 |
|
||
|
||
`seen + equal = 0` everywhere: a shared value is *always* invisible to the old
|
||
reader. And it almost never misses a field whose value differs. So "the missing
|
||
value equals the sibling's" was true **by construction** — the rule was
|
||
re-deriving the condition under which the field went missing in the first place.
|
||
|
||
That is why it looked so strong: 9/9, 4/4, 6/6 support. It could not have
|
||
failed on the cases it was fitted to.
|
||
|
||
### The two places it does fail
|
||
|
||
The `missed + differ` cells are the rule's wrong predictions, and both are real:
|
||
|
||
* `UN_e104_ADAN_Carrier` — `DefencePoint` is **0.2**; the rule predicts
|
||
`AttackVesselPoint` = 0.003.
|
||
* `UN_e011_ADAN_Attacker_B_HF_Wayne` — `FCSRange` is **3000.0**; the rule
|
||
predicts `RadarRange` = 6000.0.
|
||
|
||
**Use the record table.** The rules are unnecessary where they are right and
|
||
wrong where they are not.
|
||
|
||
### The original text, kept for the reasoning
|
||
|
||
**Confidence: 🟡 for `Size_Y`, ❔ for the rest. Analysis 2026-08-10, offline, from
|
||
[`captures/unit-runtime-fields.csv`](../captures/unit-runtime-fields.csv).**
|
||
|
||
The coverage limit above (21 of 110 units, growing only with story progress) is
|
||
worth attacking from the other side first: *if* a field the disc leaves unset
|
||
always took the same runtime value, the 21 captured units would pin that default
|
||
for all 110 and no further missions would be needed.
|
||
|
||
**It does not.** Restricting to the 150 values that are both ✅ CONFIRMED and
|
||
come from a field the disc leaves defaulted, only 6 of 24 fields have a single
|
||
value across every unit that defaults them (`HP`→10, `MassScore`→0,
|
||
`MaximumVelocity`→0, `RadarRange`→0, `DestroyMotionTime`→0, `Size_Z`→0.1). The
|
||
other 18 take several distinct values — so the default is computed per unit.
|
||
|
||
Where from? For each defaulted value, ask which *other* field of the same unit
|
||
holds exactly that value. Counting only cases where the value is **non-zero**
|
||
(otherwise `0 == 0` inflates every pair) and checking that the two fields are at
|
||
**different offsets** (so the match is not the layout solver aliasing them):
|
||
|
||
| defaulted field | takes the value of | support | independent units |
|
||
|---|---|---|---|
|
||
| `Size_Y` (`0x034`) | `Size_X` (`0x030`) | 9/9 | **7**, 6 distinct values |
|
||
| `Size_Radius` (`0x050`) | `min(Size_X, Size_Z)` | 4/4 | 4, 3 distinct values |
|
||
| `FCSRange` (`0x2a4`) | `RadarRange` (`0x2a0`) | 4/4 | 2 |
|
||
| `DefencePoint` (`0x2bc`) | `AttackVesselPoint` (`0x2b4`) | 6/6 | 2 |
|
||
|
||
`Size_Y ← Size_X` is the one to trust: seven unrelated ships (`e105` 600,
|
||
`e106` 300, `e108` 80, `e201` 300, `f101` 400, `f105` 700, `f106` 200) each omit
|
||
`Size_Y` on disc and each shows its own `Size_X` at runtime. When both fields
|
||
*are* on disc they differ freely (14 distinct `Size_Y` values against 13 of
|
||
`Size_X`), so this is a default rule, not one value stored twice.
|
||
|
||
`Size_Radius`'s formula is **not yet separable**: `min(Size_X, Size_Z)` and "the
|
||
median of the three axes" fit all four units identically. `UN_e010_ADAN_Attacker_S`
|
||
is what rules out the simpler `Size_Radius ← Size_X` (X=100, Y=40, Z=50, radius
|
||
**50**). The last two rules rest on two independent units each and are ❔ —
|
||
recorded so they can be falsified, not relied on.
|
||
|
||
**Why it matters for the reimplementation:** filling a missing `Size_Y` with `0`
|
||
or with a global constant gives the game's largest hulls a wrong lateral extent
|
||
(`f105` 700, `e105` 600, `f101` 400 — all defaulted on disc). Applied across the
|
||
disc, the rules recover **65 (unit, field) values in units that have never been
|
||
visited**: `Size_Y` in 21 of the 21 units that omit it, `Size_Radius` in 22 of 26,
|
||
`FCSRange` in 14 of 56, `DefencePoint` in 8 of 60.
|
||
|
||
### Cross-check against the weapons: this is NOT an engine-wide mechanism
|
||
|
||
The obvious worry is that four rules from 21 units are coincidence. The
|
||
`Weapon`/`Shell` capture is the control: **complete coverage, 126 records**, with
|
||
the same "defaulted on disc" classification. Running the identical sweep there
|
||
(confirmed rows, non-zero values, offsets required to differ) finds **no sibling
|
||
rule at all** — the single 100 %-agreement candidate (`Shell.Length ←
|
||
`Shell.Volume`, 5 records) has one distinct value, i.e. it is really the constant
|
||
`Length → 10` coinciding with `Volume = 10`. Weapon defaults vary per record just
|
||
as unit defaults do (10 of 14 `Weapon` fields, 16 of 17 `Shell` fields), so the
|
||
phenomenon is general; the *sibling* explanation is not.
|
||
|
||
So `Size_Y ← Size_X` is **specific to the unit schema** (plausibly the size block
|
||
defaulting its axes), not a property of IDXD default resolution. Two consequences:
|
||
the rule cannot be justified by appeal to a general mechanism, and the two
|
||
two-unit hypotheses (`FCSRange`, `DefencePoint`) lose the support they would have
|
||
borrowed from one — treat them as **coincidence-not-excluded** until a new stage
|
||
tests them.
|
||
|
||
`Size_Y ← Size_X` itself survives this scrutiny, and was re-checked at the raw
|
||
token level rather than through the sub-record merge: `UN_e105_ADAN_Cruiser`,
|
||
`UN_f105_TCAF_Cruiser` and `UN_f101_TCAF_Acropolis` each declare `Size_X`,
|
||
`Size_Z` and `Size_Radius` and **no `Size_Y` at all**, and each reads back its own
|
||
`Size_X` (600 / 700 / 400) at runtime.
|
||
|
||
**How to falsify:** the rules predict a specific number for units in stages not
|
||
yet captured. Load any new stage, snapshot, and compare — one disagreement kills
|
||
the rule. Note what is *not* a useful test: Stage 01, the only other reachable
|
||
stage, adds just four uncaptured units (`e010`/`e106` variants) whose predictions
|
||
are the same numbers their already-captured base variants gave, so it would
|
||
re-measure rather than test. A real test needs a stage with unfamiliar classes,
|
||
i.e. story progress — which is now the *only* thing story progress is needed for
|
||
here.
|
||
|
||
## Cross-validated against the loader itself (2026-08-13)
|
||
|
||
This layout was solved by binding disc values to RAM words. It has now been
|
||
derived a second time, independently, **from the game's own code**: the loader
|
||
`sub_82341A20` builds every key as `addi r4, r30, -N` (`r30 = 0x82088f94`), so the
|
||
field *name* for each store is a string in the executable, and pairing each key
|
||
with the first store after its accessor call gives the offset without any value
|
||
matching. See [`live-unit-definitions.md`](../live-unit-definitions.md).
|
||
|
||
**The two agree completely where they overlap: 25 shared offsets, 25 agree, 0
|
||
disagree.** Each also covers what the other misses — the code-derived table has
|
||
**159 fields** (vs 27 confirmed here) and is checked in as
|
||
`crates/sylpheed-formats/data/unit_definition_layout.txt` with
|
||
`sylpheed_formats::unit_layout` and a no-emulator regression test; this table
|
||
still uniquely holds **`ScorePoint` (`+0x08c`)** and **`MassScore` (`+0x094`)**,
|
||
which the key-string extraction did not pick up.
|
||
|
||
⚠️ **One tentative entry here is an artefact and should not be trusted.**
|
||
`Slalom_CutoffRatio` at `+0x00c` is recorded with values like `2.8026e-45` and
|
||
`1.4013e-45` — those are the **denormal float readings of the integers 2 and 1**.
|
||
`+0x00c` is below the first field the loader ever names (`+0x018`), and the same
|
||
trap produced a false `YawDragFactor → +0x0c` hit when the code-derived map was
|
||
being built. It is an int-typed word, not a ratio.
|
||
|
||
## Stage select makes the coverage limit go away (2026-08-13)
|
||
|
||
Unit definitions are instantiated **per stage**, so this note's coverage figure
|
||
(21 units, "grows by visiting missions") was blocked on progress. It no longer is:
|
||
the save's stage field is solved ([savegame](savegame-format.md#-solved-52-is-the-stage-and-the-save-picks-the-mission-2026-08-13)),
|
||
so any story stage can be flown from a hand-edited save and snapshotted.
|
||
|
||
First harvest — a mission at the **Night Ravens** (027 objectives, a different
|
||
roster entirely):
|
||
|
||
| | before | after |
|
||
|---|---|---|
|
||
| units with runtime values | 21 | **26** |
|
||
| rows in [`unit-runtime-fields.csv`](../captures/unit-runtime-fields.csv) | 1 827 | **2 143** |
|
||
| defaulted-on-disc values | 963 | **1 059** |
|
||
|
||
New units: `UN_e004_ADAN_ElanPlus_N`, `UN_e006_ADAN_Vindicator_Margras`,
|
||
`UN_f002_TCAF_DeltaSaber_W`, `UN_f002_TCAF_DeltaSaber_W_Player` (a second player
|
||
craft!) and `UN_mn040_Asteroid_Big` (47 defaulted values, `Size_Z = 2000`).
|
||
|
||
**Two traps, both paid for in this run:**
|
||
|
||
1. **`launch_mission.sh` does not load slot 01.** The LOAD GAME list preselects the
|
||
**last-used** slot — here **03** — and the script simply presses `A` on it, so a
|
||
probe written to slot 01 is ignored and the game flies whatever that slot holds.
|
||
The first "stage 14" snapshot was therefore stage 02 again, and the solver
|
||
correctly reported **0 new units**. Fix used: **patch every slot** (01/02/03) to
|
||
the target stage, or drive the list explicitly and confirm the highlighted slot
|
||
by screenshot before pressing `A`.
|
||
2. **Back up before patching, not after.** The backup taken mid-run already
|
||
contained the probe, so "restoring" it restored the probe; the pristine copy from
|
||
the earlier session was what actually restored slot 01 (`142b4f43…`, verified).
|
||
|
||
Regenerating the solver's token file: `idxd_tokens <GP_MAIN_GAME_E.pak> Generic`
|
||
emits the `REC`/`F` records `unit_runtime.py` wants — **110** of them are `UN_*`,
|
||
which is the disc's full unit count.
|
||
|
||
### Reading the objects with the code-derived layout, not the solver (2026-08-13)
|
||
|
||
`unit_runtime.py` places a field only if the **disc** values it somewhere — that is
|
||
what lets it score `(field, offset, encoding)` triples — so it resolved 58 of 153
|
||
fields from a single-mission snapshot. The layout in
|
||
[`data/unit_definition_layout.txt`](../../../crates/sylpheed-formats/data/unit_definition_layout.txt)
|
||
came from the title's own loader instead (`sub_82341A20`, where the field *name* of
|
||
every store is a string in the image), so it places **all 159**, including the ones
|
||
no disc record ever sets — which is precisely the Route-B target.
|
||
|
||
[`tools/re-capture/unit_dump_layout.py`](../../../tools/re-capture/unit_dump_layout.py)
|
||
reads every field of every live definition object with that layout, and keeps the
|
||
project's discipline: a field the disc **does** value is a **check**, not a new
|
||
value. Over two snapshots (19 objects): **700 disc cross-checks agree, 0 disagree.**
|
||
|
||
| | solver-derived | layout-derived |
|
||
|---|---|---|
|
||
| fields placed per object | 58 of 153 | **159** |
|
||
| rows over these 19 units | 609 | **2 736** |
|
||
| defaulted-on-disc values, disc-wide file | 1 059 | **2 351** |
|
||
|
||
Two things the run pinned down, both cheap to re-learn the hard way:
|
||
|
||
* **The layout table's offsets are DECIMAL** (`48 f32 Size_X`), while the solver's
|
||
CSV prints hex. Parsing it as hex puts every field `0x18` bytes late — and the
|
||
disc cross-check then fails on *everything*, which is exactly how the mistake
|
||
announced itself.
|
||
* **Angle fields can carry a prefix.** `AB_AA_PitchPlus` (afterburner) is still an
|
||
angle, so anchoring the `AV_`/`AA_` test at the start of the name reported two
|
||
player-craft fields as contradictions when they were 15°/16° in radians. With the
|
||
token matched anywhere, the cross-check is clean.
|
||
|
||
⚠️ One unit's object is **not** byte-identical between the two missions —
|
||
`UN_f201_TCAF_Tanker`. Either a per-mission override or a field the runtime mutates;
|
||
the merged CSV holds the later reading, and separating them needs a third snapshot.
|
||
|
||
### Targeting the next mission, and the check that these really are definitions (2026-08-13)
|
||
|
||
[`examples/roster_target.rs`](../../../crates/sylpheed-formats/examples/roster_target.rs)
|
||
ranks the stages by how many of their roster units are **not yet harvested**. The
|
||
rosters are `EnumUnit_S<NN>` tables whose TOC entries store a *path hash*, so the
|
||
stage label comes from hashing the candidate paths
|
||
(`hash::TOC_NAME_SCHEMES`) rather than from `UnitRoster::stage`, which can only
|
||
infer a tag when the roster happens to carry a `UN_S<NN>_…` prop.
|
||
|
||
It picked **S09 (10 missing)**; flying it took the file to **36 units / 4 785 rows /
|
||
3 439 defaulted-on-disc values**, adding `UN_e102_ADAN_Battleship`,
|
||
`UN_e104_ADAN_Carrier`, `UN_e107_ADAN_AAFrigate`, `UN_e011_ADAN_Attacker_B`,
|
||
`UN_e008_ADAN_TurretPlus`, `UN_be001_ADAN_TerrafoamingUnit`,
|
||
`UN_e001_ADAN_Elan_GR{,_Violeta}`, `UN_f102_TCAF_LightCarrier_Inv` and
|
||
`UN_f106_TCAF_Destroyer_Inv`.
|
||
|
||
**The objects are mission-independent — measured, not assumed.** Eleven units appear
|
||
in more than one snapshot, and four of them are *not* byte-identical across missions.
|
||
Comparing them **through the layout**: **zero mapped fields differ**. The 12 differing
|
||
4-byte slots are all **unmapped** — offsets `4/8/16/20` (the object header and name
|
||
pointer) and `0x250/0x268/0x300–0x308/0x330–0x338` (sub-object pointers) — i.e. guest
|
||
addresses, not data. So a value harvested in one mission is the unit's definition, not
|
||
a per-mission tweak, and the earlier `UN_f201_TCAF_Tanker` flag resolves the same way.
|
||
|
||
Cross-checks against the disc over the three snapshots: **1 052 agree, 0 disagree.**
|
||
|
||
One more angle field turned up the same way as the last: `Through_AngleMaximum`
|
||
(object `1.0472` = 60° in radians) carries neither an `AV_`/`AA_` token nor `Bank`, so
|
||
the degrees↔radians rule covers **anything with `Angle` in the name** too.
|
||
|
||
### Harvest state: 60 of 110 units, and where the remaining 50 live (2026-08-13)
|
||
|
||
Six targeted missions (S03, S06, S09, S13, S15, S16, plus the earlier Night Ravens
|
||
run) take the file to **60 units · 8 241 rows · 6 071 defaulted-on-disc values**,
|
||
from 21 units / 963 values this morning. Cross-checks against the disc across every
|
||
snapshot: **agree, 0 disagree** — the discipline never had to be relaxed.
|
||
|
||
Two results worth calling out:
|
||
|
||
* **`UN_e901_ADAN_Boss` and `UN_e910_core_ADAN_GeneratorCore` are harvested**
|
||
(HP 10 000, `Size_X` 500, `Size_Radius` 250, `MaximumVelocity` 450) — **without the
|
||
boss ever appearing on screen**. Definitions are instantiated when the stage loads,
|
||
so a unit only has to be in the mission's roster, not in view. That is a much
|
||
weaker requirement than the capture work needs, and it is why the same stage that
|
||
could not give the `e901` geometry gave its stats immediately.
|
||
* **The extra stages are not reachable this way.** Setting the save's stage field to
|
||
**27** boots to the title and then the emulator exits during the load — likewise
|
||
the other `S24…S29` entries. Story stages 1–16 all load normally. So the field
|
||
addresses the **story campaign only**, and the EX/challenge rosters need whatever
|
||
menu path Challenge mode uses.
|
||
|
||
That bounds the rest of the work: of the units still unread, the roster ranking puts
|
||
almost all of them in `S24/S25/S27/S28/S29` (`*_EX4`, `*_EX5`, `*EX` variants,
|
||
`UN_f004_TCAF_DeltaSaber_A_Player`).
|
||
|
||
**S08 closed the story stages** — `UN_be005_ADAN_SpaceFortress` and
|
||
`UN_mn500_ADAN_FloatingMine` (plus six `UN_S08_Asteroid_cmesh_*` collision meshes),
|
||
taking the file to **68 units · 9 393 rows · 7 115 defaulted-on-disc values**. Every
|
||
unit any story mission fields is now read.
|
||
|
||
**Probe: `Game Status = GAME_CLEAR` does NOT unlock a Challenge entry.** Setting GHAD
|
||
`+48` (and its header mirror `+0x18`) to **2** — the `STATE_GAME_CLEAR` end of the
|
||
enum this doc names — leaves both menus exactly as they were: the title still offers
|
||
`NEW GAME / LOAD GAME / TUTORIAL / OPTIONS / EXTRAS`, and EXTRAS still offers only
|
||
`MISSION SELECT / MOVIE THEATER / BACK`. So the EX rosters are not gated on that
|
||
field, and `GP_CHALLENGE.pak`'s content is reached some other way (or not at all from
|
||
these screens).
|