Dumping 512 words instead of 96 finds exactly the same 4 of ~50 disc values, for all five correlated units, so the rest of the record is not in this structure. Also corrects the reason recorded last commit: get_f32 is NOT unreliable on default-heavy records -- it resolves 46-57 numeric fields per unit, which is what made the emptiness of the correlation measurable in the first place. The one automated hit (YawDragFactor -> +0x0c) remains false: +0x0c holds the integer 2 as a denormal and collided with the float 2.0. Next probe is a RAM-wide search for a unit-distinctive value, not a bigger window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9.1 KiB
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).
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=sdlwithSDL_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 withAudioSystem::RegisterClient: CreateDriver failed for index=0, the guest never gets past the movie, and the window stays black for 8+ minutes.nav_probe.shandlaunch_mission.shalready 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.shreached 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,
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.