examples/roster_target.rs ranks stages by how many roster units are still
unharvested. The EnumUnit_S<NN> tables are found by hashing candidate TOC paths
(hash::TOC_NAME_SCHEMES) — UnitRoster::stage can only infer a tag when the roster
carries a UN_S<NN>_ prop, which most do not.
It picked S09 (10 missing). Flying it: 26 -> 36 units, 3 345 -> 4 785 rows,
2 351 -> 3 439 defaulted-on-disc values. New: e102_Battleship, e104_Carrier,
e107_AAFrigate, e011_Attacker_B, e008_TurretPlus, be001_TerrafoamingUnit,
e001_Elan_GR{,_Violeta}, f102_LightCarrier_Inv, f106_Destroyer_Inv.
Also settled: the definition objects are mission-independent. Eleven units appear in
more than one snapshot and four are not byte-identical, but compared through the
layout ZERO mapped fields differ — the 12 differing slots are all unmapped (offsets
4/8/16/20 and 0x250/0x268/0x300-0x308/0x330-0x338: object header and sub-object
pointers). So a harvested value is the definition, not a per-mission tweak, and the
earlier UN_f201_TCAF_Tanker flag resolves the same way. Cross-checks over three
snapshots: 1 052 agree, 0 disagree.
Third angle field found the same way (Through_AngleMaximum = 60 degrees in radians),
so the degrees<->radians rule covers any name containing "Angle".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
483 lines
26 KiB
Markdown
483 lines
26 KiB
Markdown
# Runtime `Unit` struct (craft / vessel definitions) — read from live guest memory
|
||
|
||
**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.
|
||
|
||
## A defaulted unit field is not a global constant — some inherit from a sibling
|
||
|
||
**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.
|