Files
Sylpheed/docs/re/structures/weapon-struct-runtime.md
Sylpheed RE agent b217af4df4 re: every weapon placed (declared x mounted, 131); a fourth undeclared table
Partitioned all 131 Weapon.ID records against the two data routes by which a
weapon is ever named - a field name in an EnumWeapon record, and a Turret_NNN
record's WeaponID value:

                    mounted   not mounted
  declared            105         22
  not declared          0          4

105 + 22 + 0 + 4 = 131, with no unexplained cell. Both marginals re-derive
unit-substructure-records' own numbers independently: mounted 105, and
22 + 4 = its "26 weapons no turret mounts".

The empty cell is the result: nothing is mounted without being declared, 0 of
131, so declaration is the superset of mounting. The four undeclared and
unmounted records are therefore reachable by neither route. Each appears
exactly once per pak copy (x6) and only as a Weapon record's ID value; the live
control Weapon_TCAF_DeltaSaber_Beam appears x18 as an EnumWeapon field name and
x228 as a Turret_NNN.WeaponID. That places Weapon_TCAF_DeltaSaber_Laser_Ttrl,
the weapon this pass set out to find, and unifies all four as orphan datasheet
rows.

Recorded as powerless rather than as evidence: the image holds 0 exact strings
for the four, but also 0 for the live control and 0 for Weapon_NULL. Only three
Weapon_-prefixed strings exist at all and 0 contain Ttrl - the executable names
no weapon, so the negative does not discriminate.

New: sweeping EnumWeapon RECORDS rather than the three declared tables gives 130
distinct field names, not 127. The extra three come from a fourth record, pak
key 305595319, resolved as EnumWeapon_TestS01.tbl, carrying Weapon_TestS01_Gun/
_Laser/_Missile. It dangles at both ends - no stage declares it, and its three
weapons are the only declared names with no Weapon.ID record. 130 - 3 = 127
restores the previous pass's union.

All fifteen artefacts byte-identical.
2026-08-28 04:50:35 +00:00

19 KiB
Raw Permalink Blame History

Runtime Weapon / Shell structs — read from live guest memory

Confidence: CONFIRMED for the fields marked below (each binding is reproduced by 10125 independent disc records with zero contradictions); 🟡 for the thin ones. Captured 2026-07-29 from Xenia Canary running the retail disc, save slot 01 (Stage 02, 5 % progress), READY ROOM and ARSENAL.

Why this exists

weapon\Weapon_*.tbl is an IDXD record whose string pool omits every field left at its default. That parked a long list of stats as unreadable — the …Ratio / …Count family, Power, MaximumRange. The Arsenal DATA SHEET route recovered a few of them but only as letter buckets (Range/Damage as AE), and only for the 9 weapons unlocked at 5 % progress.

This reads the values directly out of the running game's memory instead. All 126 weapons, all fields, exact numbers, in one pass — and it needs no story progress, because the definitions are parsed at load time whether or not the player has unlocked the weapon.

The lever: Canary maps guest RAM into /dev/shm

Xenia Canary backs the entire guest address space with one shared-memory file, /dev/shm/xenia_memory_<id>. It is a plain file: the guest's RAM is readable from the host with open/seek/read, live, no debugger and no emulator patch. Guest VAs map into it through Xenia's fixed table (memory.cc), which tools/re-capture/gmem.py implements:

python3 tools/re-capture/gmem.py find "Weapon_DSaber_P_wep_01"   # search all of RAM
python3 tools/re-capture/gmem.py words 0xbccce500 48             # dump as BE u32/f32

The file is sparse (~212 MB resident of 4.5 GB), and the scan uses SEEK_DATA/SEEK_HOLE, so a full-RAM search costs ~0.2 s.

Finding the objects

  1. The title's schema field-name pool is in the XEX at 0x82086a30, in declaration order: EnumWeapon, type Weapon (ID, Name, TargetType, … CartridgeModelName), then type Shell (ID, Name, MovementType, … Explosion_MaxDamageRadius). This is exactly the on-disc field order. No pointer to these strings exists anywhere in RAM — PPC builds the addresses with lis/ori immediate pairs — so the schema descriptor cannot be found by pointer-chasing; the layout has to be solved instead (below).

  2. Each parsed record becomes two C++ objects, a Weapon and its Shell, each in its own contiguous array, each identified by its vtable pointer:

    class vtable VA stride count
    Weapon 0x820af548 0xc0 126
    Shell 0x820af58c 0x200 126

    The vtable VAs are static (XEX .data); the array base addresses are heap and are not assumed — the tool locates every object by scanning RAM for the vtable word.

  3. Object +0x04 points at a 0x40-byte name record; the ID string sits at +0x10 inside it. That is what keys each object back to its disc record.

WeaponShell pairing is by ID (Weapon_XShell_X) — the two arrays are in different orders, so index-pairing would be wrong.

Solving the layout (the part that makes this evidence, not guesswork)

tools/re-capture/weapon_runtime.py brute-forces every (field, byte offset, encoding) triple and scores it against the disc: how many records does this offset reproduce, and how many does it contradict? A binding is accepted only with zero contradictions, and is marked only when ≥10 records agree on ≥3 distinct values.

That threshold matters: a field whose disc samples are all the same number matches any offset holding that constant, so agreement count alone is not evidence — the number of distinct values pinned down is. Two fields landing on one offset is impossible in a real struct, so collisions are resolved to the better-evidenced field and the loser is reported as unsolved. Bindings that contradict more than 20 % of their samples are discarded outright rather than reported on their agreeing subset.

Encodings found: f32, u32, deg (angles are stored in radians; SprayAngle, AngularVelocity and SplitCone are degrees on disc), and cnt — see the next section.

IsCharging switches the counter encoding

LoadingCount and TriggerShotCount at +0x28 / +0x44 are int32 on 118 records and float32 on 8. The 8 are exactly the records with IsCharging = Yes — an exact partition, found by testing every disc field/value pair against the float-encoded set. A charging weapon drains its magazine continuously, so its counter needs a fraction.

Any reimplementation must branch on IsCharging when reading these two fields; reading them as int unconditionally yields 1125515264 instead of 150.

Struct maps

See docs/re/captures/weapon-runtime-fields.csv for the full machine-readable table — 7 182 rows, every confirmed field × every one of the 126 records, tagged disc or defaulted-on-disc. 4 393 of those values were not readable from the disc at all.

Weapon (0xc0 bytes)

offset enc field agree distinct conf
+0x010 u32 ReticleType 62 13
+0x01c u32 SpecialWeaponType 73 6
+0x028 cnt LoadingCount 121 33
+0x02c f32 Interval 125 28
+0x030 f32 ReadyInterval 120 10
+0x034 f32 Heating 114 30
+0x038 f32 Cooling 106 21
+0x03c f32 Mass 122 42
+0x040 f32 HitRatio 102 5
+0x044 cnt TriggerShotCount 114 17
+0x048 f32 TriggerShotInterval 99 11
+0x050 deg SprayAngle 96 17
+0x06c f32 LockIntervalSingle 61 9
+0x070 f32 LockIntervalMulti 28 9
+0x09c f32 MaximumCharging 3 3 🟡

Also identified structurally, not by the solver: +0x00 vtable, +0x04 name record, +0x0c TargetType as a bitmask (Vessel|Craft|Structure = 7), +0x14/+0x18 name hashes, +0x7c/+0x80 muzzle-flash FX name record + hash.

Unsolved (no consistent offset): Cracker_ShotInterval, Cracker_SubShotCount, MinimumCharging, MultiTargetCount.

Shell (0x200 bytes)

offset enc field agree distinct conf
+0x00c cnt DeleteFadeSpeed 39 2 🟡
+0x010 cnt GuidanceType 9 4 🟡
+0x014 cnt SpiralType 3 1 🟡
+0x020 f32 Length 107 3
+0x024 f32 Volume 19 3
+0x028 f32 ShellMass 110 34
+0x03c f32 HP 28 2 🟡
+0x040 f32 LifeTime 94 43
+0x044 f32 FadeInTime 13 2 🟡
+0x048 f32 FadeOutTime 7 1 🟡
+0x04c f32 Velocity 106 15
+0x050 f32 MinimumVelocity 11 2 🟡
+0x054 f32 MaximumVelocity 29 7
+0x058 deg AngularVelocity 49 19
+0x05c f32 Acceleration 9 4 🟡
+0x064 f32 BeginGuidanceTimeAdjust 24 2 🟡
+0x068 f32 EndGuidanceTime 19 4
+0x070 f32 Spiral_BeginTime 2 2 🟡
+0x074 f32 Spiral_BeginTimeAdjust 25 2 🟡
+0x08c f32 MinimumRange 43 7
+0x090 f32 MaximumRange 117 21
+0x0a0 f32 Color_R 18 4
+0x0a4 f32 Color_G 121 3
+0x0a8 f32 Color_B 100 2 🟡
+0x0b4 f32 Radius 89 10
+0x0b8 f32 Power 101 37
+0x0bc f32 FailedDamageRatio 2 2 🟡
+0x0c0 f32 PlayerLaserPower 11 6
+0x0fc f32 ChaffResistRatio 24 1 🟡
+0x104 f32 ChargingSizeRatio 3 1 🟡
+0x10c f32 SphereRadiusBegin 9 6 🟡
+0x110 f32 SphereRadiusTurn 9 8 🟡
+0x114 f32 SphereRadiusEnd 10 8
+0x118 f32 ExplosionTurnTime 3 2 🟡
+0x11c f32 ExplosionLifeTime 7 6 🟡
+0x120 f32 ExplosionDamage_Maximum 4 4 🟡
+0x124 f32 ExplosionDamage_OuterEdge 8 6 🟡
+0x128 f32 Explosion_MaxDamageRadius 8 3 🟡
+0x130 f32 SplitTime_Maximum 2 2 🟡
+0x134 deg SplitCone 2 2 🟡
+0x148 cnt NodeCount 39 4
+0x164 f32 Deceleration 9 2 🟡

Unsolved: BeginGuidanceTime, EndGuidanceTimeAdjust, Spiral_EndTime, SplitTime_Minimum, StartingVelocity, Straight1Type, st1_df_pitch, pitch0, and the sp_qu_* / sp_sp_* / sp_zg_* spiral-motion family. Those are declared by too few records (or by none that the solver could separate) — they need a state where the shells are actually in flight.

The answer to the parked Route-B question

Player-weapon fields that are defaulted on disc, now read exactly (· = the disc carries the value already):

wep LoadingCount TriggerShotCount MaximumRange Power Heating Cooling ReadyInterval HitRatio SprayAngle
01 · 1 · · · · · · ·
02 · · · 100 · · · 1 ·
03 · · · · · · · · 1
05 · 4 · · · · · · ·
08 · · · · · · · 1 ·
09 · · · · 0.02 · · · 1
11 6 · · · · · · · ·
12 · · · · 0 · · 1 ·
13 · · · 300 · · · · ·
14 · · · · · · · · 1
19 · · · · · 0.2 · · 1
24 · 1 · · · · · · 1
25 · · 4000 · · · · · ·
26 · · · 500 · · · · ·
27 · 1 · · · · · · ·
28 5 · · · · · · · ·
29 · · · 100 · · · · ·
30 · 4 · · · · · · ·
36 5 · · · · · · · ·
37 · 1 · · · · · · 1
38 · · · · · · · 1 ·
39 · 1 · · · · · · 1
40 · · · · · · 0.1 · ·
48 · · · · · · · · 1
50 · · · · · · · · 1
52 · · · · · · · 1 ·
53 · 1 · · · · · · ·
54 · · · · · · · · 1
55 · · · · 0 · · 1 ·
56 · · · · · · · 1 ·
57 · · · · 0 · · 1 ·
58 · · 10000 · · · · · 1
59 · · · · · · · 1 1
60 · 4 · 1000 · · · · ·
62 · 1 · · 0 · · · ·
66 · · · · · · · 1 ·
67 · · · · · · · 1 ·
68 · · · · · · · 1 ·
69 · · · · · · · 1 ·
70 0 · · · · · · 1 ·
71 · · · 10 · · · 1 ·
81 · · · 300 · · · · ·
82 · · 10000 · · · · · 1
84 · · · · · · · · 0.1
85 · 1 · · 0 · · · ·

HitRatio is 1.0 for every one of the 24 records that default it — the whole …Ratio family that blocked Route B is simply "no penalty". wep_82's defaulted field is PlayerLaserPower = 12000 (its Power, 12000, is on disc).

Cross-checks

  • Against the independent screenshot route. The Arsenal DATA SHEET (weapon-datasheet-runtime.md) established Max. Lock Ons == TriggerShotCount, and read 4 for wep_05 and wep_60 off the panel. The memory read gives 4 for both — two unrelated methods, same numbers.
  • Against the in-flight HUD. NOSE BM 06000 / MAIN MPM 00300 matches wep_01 LoadingCount = 6000 and wep_02 = 300 at +0x28.
  • Stability. All 252 objects are byte-identical between the READY ROOM and the ARSENAL, so these are load-time definition data, not transient state.
  • Self-consistency. 121 records agree on LoadingCount's offset across 33 distinct values with zero contradictions; Power, 101 records / 37 distinct values. A wrong offset cannot do that.

Incidental findings

  • A duplicate, conflicting disc record. Two .tbl entries declare Shell_TCAF_Ship_AAGun; one sets Power = 60.0, the other omits it. The runtime object holds 5, i.e. the omitting declaration won. Any reimplementation loading both will silently pick one — this says which.

  • Not every disc record is instantiated. 131 disc records produced 126 objects; the 5 with no runtime object are context variants that this save never loads — Weapon_TCAF_DeltaSaber_NoseGun_Ttrl / _Laser_Ttrl (tutorial), _NoseGun_None, Weapon_TCAF_Ship_AAGun_EX5, Weapon_ADAN_Attacker_S_GunTurret. No runtime object lacked a disc record.

    🔴 Followed up statically 2026-08-28 — and "running the tutorial should instantiate the _Ttrl pair" is refuted. The disc has exactly three weapon tables, and each is a single EnumWeapon record whose field names are the weapons: EnumWeapon_Test.tbl (126), EnumWeapon_EX5.tbl (127 — the same 126 plus one), EnumWeapon_Tutorial.tbl (9, a strict subset of Test). Union: 127.

    of the five declared by
    Weapon_TCAF_Ship_AAGun_EX5 EnumWeapon_EX5.tbl only — and that table belongs to S28 alone (stage-numbering-and-player-craft)
    _NoseGun_Ttrl, _Laser_Ttrl, _NoseGun_None, Weapon_ADAN_Attacker_S_GunTurret no weapon table at all

    So one of the five is explained — it loads only in Stage 28 — and the other four are declared by nothing on the disc, tutorial included: the nine names in EnumWeapon_Tutorial.tbl are Weapon_NULL, Weapon_ADAN_Puppy_NoseGun, Weapon_DSaber_P_wep_{01_Beam,02_Missile,58_Laser} and Weapon_TCAF_DeltaSaber_{Beam,Cannon,Missile,ASMissile}neither _Ttrl weapon is among them. Running the tutorial will not instantiate them.

  • Defaulted Power values vary per weapon (1, 10, 100, 300, 500, 1000), so they are not one constructor constant. Where they come from — the IDXD's undecoded binary node/index region, or per-type code defaults — is not determined here (NEEDS-HUMAN / follow-up). Either way the runtime values above are ground truth, and they are now a decoding oracle for that region.

Reproduce

export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy
run-canary --audio --apu=sdl --log_mask=13 --logged_profile_slot_0_xuid=E0300000EFBEA3D4 &
tools/re-capture/skip_intro.sh          # -> title
# LOAD GAME -> slot 01 -> YES -> READY ROOM  (weapon tables load with the save)

cargo run --release -p sylpheed-formats --example idxd_tokens -- \
    <disc>/dat/GP_MAIN_GAME_E.pak > /tmp/wep_tokens.txt
python3 tools/re-capture/weapon_runtime.py /tmp/wep_tokens.txt          # report
python3 tools/re-capture/weapon_runtime.py /tmp/wep_tokens.txt --csv    # full table

What this unlocks

gmem.py + "scan for the vtable, solve the layout against disc ground truth" is not weapon-specific. The same three steps apply to any IDXD-backed definition whose fields the disc defaults — craft/UNIT stats, which the Hangar UI exposes only as a Gross Weight class and which the menu route could not reach at all, are the obvious next target.

Every weapon placed: declared × mounted, 131 with no unexplained cell

The previous pass left four weapons "declared by no table". Partitioning all 131 Weapon.ID records against the two routes by which a weapon is ever named in data — a field name in an EnumWeapon record, or a Turret_NNN record's WeaponID value — closes them:

mounted on a turret not mounted
declared by an EnumWeapon table 105 22
not declared 0 4

105 + 22 + 0 + 4 = 131. Two independent checks land exactly:

  • 105 mounted re-derives unit-substructure-records's own "Weapon.ID 131, mounted 105" from a different direction.
  • 22 + 4 = 26 re-derives that document's "The 26 weapons no turret mounts".

🔑 The empty cell is the result. Nothing is mounted without being declared — 0 of 131. Declaration is the superset of mounting, so the four undeclared-and-unmounted records are reachable by neither data route:

Weapon_TCAF_DeltaSaber_Laser_Ttrl      Weapon_TCAF_DeltaSaber_NoseGun_Ttrl
Weapon_TCAF_DeltaSaber_NoseGun_None    Weapon_ADAN_Attacker_S_GunTurret

That places Weapon_TCAF_DeltaSaber_Laser_Ttrl, the one this pass set out to find, and unifies all four: orphan datasheet rows. Sweeping every archive, each appears exactly once per pak copy (×6) and only as a Weapon record's ID value — never as an EnumWeapon field, never as a WeaponID, never anywhere else. The live control Weapon_TCAF_DeltaSaber_Beam behaves completely differently: ×18 as an EnumWeapon field name and ×228 as a Turret_NNN.WeaponID.

⚠️ One route was run and has NO power — recorded so it is not re-run

"Does the executable name them instead?" The image contains 0 exact strings for all four — but it also contains 0 for Weapon_TCAF_DeltaSaber_Beam and 0 for Weapon_NULL. Only three strings start with Weapon_ at all (Weapon_Release, Weapon_NoEquipment, Weapon_CurrentWeapon — UI/field keys, not weapon ids), and 0 contain Ttrl. The image names no weapon whatsoever, so this negative does not distinguish the four from a live weapon. Powerless, not evidence.

A fourth EnumWeapon table — EnumWeapon_TestS01.tbl, declared by nothing

Sweeping EnumWeapon records rather than the three declared tables gives 130 distinct field names, not the 127 of the previous pass. The extra three come from a fourth record, pak key 305595319, present in the same six archives and carrying exactly three fields — Weapon_TestS01_Gun, _Laser, _Missile. Its name resolves as EnumWeapon_TestS01.tbl.

It dangles at both ends: no stage declares it (the 22 stage objects use only _Test, _EX5, _Tutorial), and none of its three weapons has a Weapon.ID record — they are the only 3 of the 130 declared names with no datasheet row. 130 3 = 127, restoring the previous pass's union. A shipped table, referenced by nothing, naming weapons that do not exist.