Applying the map to turrets, fighters, an attacker and a station (UNIT records rather than VESSEL) gives 406 field values agreeing with the disc and 0 disagreeing across 11 objects, so one layout serves both schemas. Two consequences the four-value signature could not give: the map IDENTIFIES an object -- 0xbd3b6a00 is UN_bf001_TCAF_SchlosBase (14 agree, 0 disagree), not UN_be005_ADAN_SpaceFortress (12 agree, 2 disagree on Color_R/Color_G) -- and variants are provably identical, with ArrowHead/_EX4/_EX5 at 48/48, Turret/_EX4 at 51/51 and three DeltaSaber player variants at 49/49. 121 defaulted fields now have runtime values. FiringRange (0 in all 11) and ShieldRatio (1 in all 6) look like true constants; HQRatio, ThrusterRatio and AttackCraftPoint vary per unit and are recorded as such. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
459 lines
24 KiB
Markdown
459 lines
24 KiB
Markdown
# 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.
|