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.
19 KiB
Runtime Weapon / Shell structs — read from live guest memory
Confidence: ✅ CONFIRMED for the fields marked ✅ below (each binding is reproduced by 10–125 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 A…E), 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
-
The title's schema field-name pool is in the XEX at
0x82086a30, in declaration order:EnumWeapon, typeWeapon(ID,Name,TargetType, …CartridgeModelName), then typeShell(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 withlis/oriimmediate pairs — so the schema descriptor cannot be found by pointer-chasing; the layout has to be solved instead (below). -
Each parsed record becomes two C++ objects, a
Weaponand itsShell, each in its own contiguous array, each identified by its vtable pointer:class vtable VA stride count Weapon0x820af5480xc0126 Shell0x820af58c0x200126 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. -
Object
+0x04points at a 0x40-byte name record; the ID string sits at+0x10inside it. That is what keys each object back to its disc record.
Weapon ↔ Shell pairing is by ID (Weapon_X ↔ Shell_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 forwep_05andwep_60off the panel. The memory read gives 4 for both — two unrelated methods, same numbers. - Against the in-flight HUD.
NOSE BM 06000/MAIN MPM 00300matcheswep_01LoadingCount = 6000andwep_02= 300at+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
.tblentries declareShell_TCAF_Ship_AAGun; one setsPower = 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
_Ttrlpair" is refuted. The disc has exactly three weapon tables, and each is a singleEnumWeaponrecord 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_EX5EnumWeapon_EX5.tblonly — and that table belongs to S28 alone (stage-numbering-and-player-craft)_NoseGun_Ttrl,_Laser_Ttrl,_NoseGun_None,Weapon_ADAN_Attacker_S_GunTurretno 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.tblareWeapon_NULL,Weapon_ADAN_Puppy_NoseGun,Weapon_DSaber_P_wep_{01_Beam,02_Missile,58_Laser}andWeapon_TCAF_DeltaSaber_{Beam,Cannon,Missile,ASMissile}— neither_Ttrlweapon is among them. Running the tutorial will not instantiate them. -
Defaulted
Powervalues 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.ID131, 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.