Files
Syplheed-Reborn/docs/re/live-unit-definitions.md
Claude (auto-RE) d674cddad4 re(route-b): full field map verified 154/154, and 101 defaulted fields read out
Anchoring each key string to the first store AFTER the accessor call makes the
extraction exact: 159 fields, 125 of them floats, and verify_fieldmap now checks
every one against the live objects -- 154 agree with the disc, 0 disagree.

The last ten mismatches taught the rule: angle fields are degrees on disc and
RADIANS at runtime (AV_PitchPlus_Max 3.6 -> 0.0628319; MaximumBank_Normal 60 ->
1.0472), an exact conversion across every angle field and unit.

101 defaulted fields now have runtime values: angle limits default to 3.6 degrees,
afterburner/turn-attack fields to 0, FiringRange to 0 (matching the loader's miss
value), Size_Y to that unit's Size_X, and the ratio family is per-unit rather than
constant (HQRatio 0.2 or 1, DefencePoint 0.1 or 0.25, ...).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:44:57 +00:00

22 KiB
Raw Blame History

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=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, 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 523 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 4657 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 +0x340x82217e18 (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, 880bl 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_YSize_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 0x82341e4c0x82341ebc 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). 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.