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.
371 lines
19 KiB
Markdown
371 lines
19 KiB
Markdown
# 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_<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`](../../../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`. 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
|
||
|
||
```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 -- \
|
||
<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](../weapon-datasheet-runtime.md), 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](unit-substructure-records.md)'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.
|