# 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](../INDEX.md) 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](../weapon-datasheet-runtime.md) 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_`. 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`](../../../tools/re-capture/gmem.py) implements: ```bash 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. `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`](../../../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`](../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](../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`. Running the tutorial should instantiate the `_Ttrl` pair. No runtime object lacked a disc record. - 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 ```bash 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 -- \ /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](../weapon-datasheet-runtime.md), are the obvious next target.