re: the Hangar loadout system -- loadout, allow-list, arsenal item

15 loadout records, one per flight position x pilot (Bird1-Sandra ..
Rhino4-Yoji), each with Arm1/Arm2/Arm3/Nose + UnitID.

The same trap as PlayerWeapon, one level up: Arm1/Arm2/Arm3/Nose do NOT
name items.  They name a per-slot ALLOW-LIST record -- one of 24 whose
only named field is Type (the slot kind) -- and the candidate items are
that record's positional, unnamed fields, in order.  Four hops:

  Rhino4-Yoji.Arm1 -> STANDARD_ARM1 -> [Falcon_9AM, Condor_105AM, ...]
  -> item.PlayerWeapon = Turret_NNN -> slot.WeaponID -> Weapon.ID

Controls: Arm1/2/3/Nose -> allow-list record 60/60; allow-list
positional entries -> arsenal item 70/88, and every one of the 18
misses is the single sentinel No_Equipment -- one of the four
WEAPONS-roster values with no item record, i.e. the empty-slot marker.

UnitID is two ID spaces at once: 5 rows name a unit Generic.ID (the
three -Katana rows are the player -- a _Player craft plus an extra,
empty PlayerUnit field), 8 name a character, resolving as Character +
the value into the 64-record character table.

Two values resolve to nothing, both single rows against 13 that do:
Rhino2-Ellen.UnitID = UN_f001_TCAF_DeltaSaber_W exists nowhere (checked
as a Generic.ID across every pak and as a record name, 0 hits) while
UN_f002_TCAF_DeltaSaber_W does -- consistent with a shipped typo,
reported not diagnosed -- and Rhino4-Brandon.UnitID = BRANDON has no
CharacterBRANDON among the 64.

New structure doc, artefact and regenerator; the other four artefacts
regenerate byte-identical.
This commit is contained in:
Sylpheed RE agent
2026-08-27 12:58:53 +00:00
parent 21e710a531
commit 2b2d984f0c
5 changed files with 287 additions and 0 deletions

View File

@@ -827,6 +827,42 @@ unknown, what evidence exists, and what the first step would be. Move an item in
field; the stat keys in records not opened here (`MegaTons`, `BulletLimit`,
`CrewCount`, `HP_CLASS`); why the roster lists 59 of 131.
***(2026-08-27) THE HANGAR LOADOUT SYSTEM — the layer above the item chain.**
[structures/hangar-loadout-system](structures/hangar-loadout-system.md),
artefact `data/hangar-loadouts.txt`, regenerator
`tools/re-capture/hangar_loadouts.py`.
**15 loadout records**, one per flight position x pilot (`Bird1-Sandra`
`Rhino4-Yoji`), each with `Arm1`/`Arm2`/`Arm3`/`Nose` + `UnitID`.
🔑 **The same trap as `PlayerWeapon`, one level up**: `Arm1`/`Arm2`/`Arm3`/
`Nose` do NOT name items — they name a **per-slot ALLOW-LIST record**, one of
24 whose only *named* field is `Type` (the slot kind). The candidate items are
the allow-list's **positional, unnamed fields, in order**. Four hops:
`Rhino4-Yoji.Arm1``STANDARD_ARM1``[Falcon_9AM, Condor_105AM, …]`
`item.PlayerWeapon = Turret_NNN``slot.WeaponID``Weapon.ID`.
🧪 Controls: `Arm1/2/3/Nose` → allow-list record **60/60**; allow-list
positional entries → arsenal item **70/88**, and **every one of the 18 misses
is the single sentinel `No_Equipment`** — one of the four `WEAPONS`-roster
values with no item record, i.e. the empty-slot marker, not a decode failure.
Six sets: `STANDARD_*` (11/9/9/8), `ExSET_*` (10/8/4/7), `S01SET_*` (2/1/1/1),
`S02SET_*` (3/2/2/1), `PlayerSET_*` (2/1/1/1), `NULL_*` (1 each) — so the
`SNNSET_` sets are stage-scoped narrowings of `STANDARD_`.
🔑 **`UnitID` is TWO ID spaces at once**: 5 rows name a unit `Generic.ID`
(the three `-Katana` rows are the PLAYER — they point at a `_Player` craft and
carry an extra, empty `PlayerUnit` field), 8 name a character, resolving as
**`Character` + the value** into the 64-record character table.
🟡 Two values resolve to nothing, both single rows against 13 that do:
`Rhino2-Ellen.UnitID = UN_f001_TCAF_DeltaSaber_W`**no such unit anywhere**
(checked as a `Generic.ID` across every pak AND as a record name, 0 hits)
while `UN_f002_TCAF_DeltaSaber_W` exists → consistent with a shipped
`f001`/`f002` typo, **reported, not diagnosed**; and `Rhino4-Brandon.UnitID =
BRANDON` — no `CharacterBRANDON` among the 64.
🟡 Not settled: who SELECTS a loadout record (the names encode flight position
and pilot, and `stage-definition-table.md` has `AI_TCAF_RhinoFlight` /
`AI_TCAF_BirdFlight`, but the join is not shown); `ExSET_*`/`S02SET_*`/`NULL_*`
are referenced by no loadout in this pak; `PlayerUnit` is empty on all three
rows that have it; `IGNORE` (13 item names) is a blocklist for something
unread.
* ✅✅ **(2026-08-27) THE 59-OF-131 ARSENAL QUESTION IS CLOSED — an Arsenal item
names a HARDPOINT SLOT, not a weapon.**
[structures/arsenal-item-weapon-chain](structures/arsenal-item-weapon-chain.md),

View File

@@ -99,6 +99,7 @@ files, which is how the same ground got covered twice.
| [`structures/sound-cue-table.md`](structures/sound-cue-table.md) | The cue index in `tables.pak` — message id -> cue -> sound id -> `.slb` bank | ✅ CONFIRMED, 1 326/1 338 script message ids bind to a bank; SOUNDS and FILES agree on the same 12 absentees, 0 orphan files |
| [`structures/cutscene-message-table.md`](structures/cutscene-message-table.md) | Cutscene dialogue — speaker, portrait, on-screen seconds, audio cue per page | ✅ CONFIRMED, field count = 9·PageCount+2 for all 7 PageCounts, 1 252/1 252 caption keys match, 138 ids close both ways |
| [`structures/isl-mission-timer.md`](structures/isl-mission-timer.md) | The mission timer — two sequential countdowns, not a limit and a warning | ✅ CONFIRMED — read from `sub_822639B8`; the threshold reading is |
| [`structures/hangar-loadout-system.md`](structures/hangar-loadout-system.md) | The Hangar loadout system — loadout → per-slot allow-list → arsenal item | ✅ CONFIRMED — 60/60 slot refs, 70/88 item refs (18 = the `No_Equipment` sentinel) |
| [`structures/arsenal-item-weapon-chain.md`](structures/arsenal-item-weapon-chain.md) | The 59-of-131 arsenal question — an item names a hardpoint slot, not a weapon | ✅✅ CONFIRMED — 0/59 `PlayerWeapon` are a `Weapon.ID`, 59/59 are a `Turret_NNN` slot |
| [`structures/unit-substructure-records.md`](structures/unit-substructure-records.md) | The destructible-subsystem model — a unit's `Turret`/`Bridge`/`Hatch`/`ShieldGenerator`/`Thruster` sub-records | ✅ CONFIRMED — 835 turrets, 835/835 `WeaponID` resolve, `StructureCount` control 667/684 |
| [`structures/unit-datasheet-static.md`](structures/unit-datasheet-static.md) | The static unit datasheet and AI flight model — `Generic`, `Maneuver`, `Effect` | ✅ CONFIRMED — 394 Generic + 114 Maneuver + 114 Effect records read |

View File

@@ -0,0 +1,61 @@
# The Hangar loadout system
# Regenerate: python3 tools/re-capture/hangar_loadouts.py
# See docs/re/structures/hangar-loadout-system.md
## counts
arsenal items 59 loadout records 15 per-slot allow-lists 24
## loadout records
Bird1-Sandra S01SET_ARM1 S01SET_ARM2 S01SET_ARM3 S01SET_NOSE UnitID=SANDRA (character)
Bird2-Billy S01SET_ARM1 S01SET_ARM2 S01SET_ARM3 S01SET_NOSE UnitID=BILLY (character)
Bird3-Antonius S01SET_ARM1 S01SET_ARM2 S01SET_ARM3 S01SET_NOSE UnitID=ANTONIUS (character)
Bird4-Carl S01SET_ARM1 S01SET_ARM2 S01SET_ARM3 S01SET_NOSE UnitID=CARL (character)
Rhino1-Katana PlayerSET_ARM1 PlayerSET_ARM2 PlayerSET_ARM3 PlayerSET_NOSE UnitID=UN_f002_TCAF_DeltaSaber_W_Player (unit) PlayerUnit
Rhino1-Raymond S01SET_ARM1 S01SET_ARM2 S01SET_ARM3 S01SET_NOSE UnitID=RAYMOND (character)
Rhino2-Ellen STANDARD_ARM1 STANDARD_ARM2 STANDARD_ARM3 STANDARD_NOSE UnitID=UN_f001_TCAF_DeltaSaber_W (???)
Rhino2-Gene STANDARD_ARM1 STANDARD_ARM2 STANDARD_ARM3 STANDARD_NOSE UnitID=GENE (character)
Rhino2-Katana PlayerSET_ARM1 PlayerSET_ARM2 PlayerSET_ARM3 PlayerSET_NOSE UnitID=UN_f001_TCAF_DeltaSaber_T_Player (unit) PlayerUnit
Rhino3-Ellen S01SET_ARM1 S01SET_ARM2 S01SET_ARM3 S01SET_NOSE UnitID=UN_f001_TCAF_DeltaSaber_T (unit)
Rhino3-Gene STANDARD_ARM1 STANDARD_ARM2 STANDARD_ARM3 STANDARD_NOSE UnitID=GENE (character)
Rhino3-Katana PlayerSET_ARM1 PlayerSET_ARM2 PlayerSET_ARM3 PlayerSET_NOSE UnitID=UN_f001_TCAF_DeltaSaber_T_Player (unit) PlayerUnit
Rhino4-Brandon STANDARD_ARM1 STANDARD_ARM2 STANDARD_ARM3 STANDARD_NOSE UnitID=BRANDON (???)
Rhino4-Ellen STANDARD_ARM1 STANDARD_ARM2 STANDARD_ARM3 STANDARD_NOSE UnitID=UN_f001_TCAF_DeltaSaber_T (unit)
Rhino4-Yoji STANDARD_ARM1 STANDARD_ARM2 STANDARD_ARM3 STANDARD_NOSE UnitID=YOJI (character)
## CONTROL loadout.Arm1/2/3/Nose names an ALLOW-LIST record
60/60
## CONTROL allow-list positional entries name an ARSENAL ITEM
70/88
misses: [('No_Equipment', 18)]
every miss is a WEAPONS-roster sentinel with no item record: True
## CONTROL loadout.UnitID
15 loadouts: 5 name a unit Generic.ID, 8 name a character (as 'Character'+value), 2 neither
neither: ['BRANDON', 'UN_f001_TCAF_DeltaSaber_W']
## the allow-lists, in order
ExSET_ARM1 Type=Arm1 10: Condor_105AM, Frail_GP37, Pilum_BP, Multi_Pike_BP, Spear_HBP, Needle_L1GP, Buzzard_10AM, Dirt_23_Rocket, Falcon_9AM, No_Equipment
ExSET_ARM2 Type=Arm2 8: White_Shark_T53R, Piranha_T53, Hound_SMH, Terrier_SMH, Fire_Arrow_L2GP, Sling_75KG, Hawk_75AM, No_Equipment
ExSET_ARM3 Type=Arm3 4: Glaive_120mm_Cannon, Monoceros_Long_Cannon, Tomahawk_a_Rail_Gun, No_Equipment
ExSET_NOSE Type=Nose 7: Machiene_Cannon_MG3, Machiene_Cannon_MG1, Broad_Sword_SG1, Saber_LG1, Dagger_BG2, Rapier_BG4H, Stiletto_BG1
NULL_ARM1 Type=Arm1 1: No_Equipment
NULL_ARM2 Type=Arm2 1: No_Equipment
NULL_ARM3 Type=Arm3 1: No_Equipment
NULL_NOSE Type=Nose 1: Stiletto_BG1
PlayerSET_ARM1 Type=Arm1 2: Falcon_9AM, No_Equipment
PlayerSET_ARM2 Type=Arm2 1: No_Equipment
PlayerSET_ARM3 Type=Arm3 1: No_Equipment
PlayerSET_NOSE Type=Nose 1: Stiletto_BG1
S01SET_ARM1 Type=Arm1 2: Falcon_9AM, No_Equipment
S01SET_ARM2 Type=Arm2 1: No_Equipment
S01SET_ARM3 Type=Arm3 1: No_Equipment
S01SET_NOSE Type=Nose 1: Stiletto_BG1
S02SET_ARM1 Type=Arm1 3: Buzzard_10AM, Falcon_9AM, No_Equipment
S02SET_ARM2 Type=Arm2 2: Terrier_SMH, No_Equipment
S02SET_ARM3 Type=Arm3 2: Tomahawk_a_Rail_Gun, No_Equipment
S02SET_NOSE Type=Nose 1: Stiletto_BG1
STANDARD_ARM1 Type=Arm1 11: Falcon_9AM, Dirt_23_Rocket, Eagle_120AM, Condor_105AM, Buzzard_10AM, Needle_L1GP, Spear_HBP, Multi_Pike_BP, Pilum_BP, Frail_GP37, No_Equipment
STANDARD_ARM2 Type=Arm2 9: White_Shark_T53R, Cauldron_50_Rocket, Piranha_T53, Hound_SMH, Terrier_SMH, Fire_Arrow_L2GP, Sling_75KG, Hawk_75AM, No_Equipment
STANDARD_ARM3 Type=Arm3 9: Glaive_120mm_Cannon, Monoceros_Long_Cannon, Arbalest_155KG, Tomahawk_a_Rail_Gun, Maelstrom_Bomb, Cluster_Mine_B10, Pointer_SM, Long_Spear_HBP, No_Equipment
STANDARD_NOSE Type=Nose 8: Stiletto_BG1, Rapier_BG4H, Dagger_BG2, Saber_LG1, Twin_Sword_SG3II, Broad_Sword_SG1, Machiene_Cannon_MG3, Machiene_Cannon_MG1

View File

@@ -0,0 +1,89 @@
# ✅ The Hangar loadout system — loadout → per-slot allow-list → arsenal item
Finishes the Hangar. [The item chain](arsenal-item-weapon-chain.md) resolved an
Arsenal item down to a `Weapon` record; this is the layer above it — **who flies
with what**, and **what the Hangar is allowed to offer for each slot**.
Artefact `../data/hangar-loadouts.txt`, regenerator
`tools/re-capture/hangar_loadouts.py`.
`GP_HANGAR_ARSENAL.pak` carries **15 loadout records**`Bird1-Sandra`,
`Bird2-Billy`, `Bird3-Antonius`, `Bird4-Carl`, `Rhino1-Katana`,
`Rhino1-Raymond`, `Rhino2-Ellen`, `Rhino2-Gene`, `Rhino2-Katana`,
`Rhino3-Ellen`, `Rhino3-Gene`, `Rhino3-Katana`, `Rhino4-Brandon`,
`Rhino4-Ellen`, `Rhino4-Yoji` — one per **flight position × pilot**, each with
`Arm1`, `Arm2`, `Arm3`, `Nose` and `UnitID`.
🔑 **Same trap as `PlayerWeapon`, one level up: `Arm1`/`Arm2`/`Arm3`/`Nose` do
not name items.** They name a **per-slot ALLOW-LIST record** — one of the 24
whose only *named* field is `Type` (the slot kind: `Arm1`/`Arm2`/`Arm3`/`Nose`).
The candidate items live in that record's **positional, unnamed fields**, in
order. So the full path is four hops:
```
Rhino4-Yoji.Arm1 ──▶ STANDARD_ARM1 (an allow-list, Type=Arm1)
──▶ [Falcon_9AM, Condor_105AM, …] (arsenal item names, in order)
──▶ item.PlayerWeapon = Turret_NNN (a hardpoint slot)
──▶ slot.WeaponID = Weapon.ID
```
## 🧪 The controls
| | |
|---|---:|
| `loadout.Arm1/2/3/Nose` names an allow-list record | **60 / 60** |
| allow-list positional entries name an arsenal item | **70 / 88** |
| — every one of the 18 misses | the single sentinel `No_Equipment` |
| `loadout.UnitID` resolves | 5 a unit `Generic.ID`, 8 a character (as `Character`+value), **2 neither** |
`No_Equipment` is one of the four `WEAPONS`-roster values that have no item
record ([the item chain](arsenal-item-weapon-chain.md)), so the residual is the
empty-slot sentinel appearing once per list — not a decode failure.
## The six allow-list sets
| set | Arm1 | Arm2 | Arm3 | Nose | used by |
|---|---:|---:|---:|---:|---|
| `STANDARD_*` | 11 | 9 | 9 | 8 | Rhino 2/3/4 wingmen |
| `ExSET_*` | 10 | 8 | 4 | 7 | — (not referenced by any loadout here) |
| `S01SET_*` | 2 | 1 | 1 | 1 | Bird flight, `Rhino1-Raymond`, `Rhino3-Ellen` |
| `S02SET_*` | 3 | 2 | 2 | 1 | — |
| `PlayerSET_*` | 2 | 1 | 1 | 1 | the three `…-Katana` rows |
| `NULL_*` | 1 | 1 | 1 | 1 | — (`No_Equipment`, `No_Equipment`, `No_Equipment`, `Stiletto_BG1`) |
So a wingman's weapon choice is drawn from a small, ordered, *per-slot* list,
and the `SNNSET_` sets are stage-scoped variants of it — `S01SET_ARM1` offers
two Arm1 items, `STANDARD_ARM1` offers eleven.
## `UnitID` is two ID spaces at once
* **5 rows name a unit** — `UN_f001_TCAF_DeltaSaber_T`,
`…_T_Player`, `…_f002_W_Player`. The three `-Katana` rows are the **player**:
they carry an extra (empty) `PlayerUnit` field and point at a `_Player` craft.
* **8 rows name a character** — `SANDRA`, `BILLY`, `ANTONIUS`, `CARL`,
`RAYMOND`, `GENE` ×2, `YOJI`. None is a `Generic.ID` on its own; all resolve
as **`Character` + the value** into the 64-record character table
([the `Generic` partition](unit-datasheet-static.md)).
## 🟡 Two values that resolve to nothing
* **`Rhino2-Ellen.UnitID = UN_f001_TCAF_DeltaSaber_W`.** No such unit exists —
checked as a `Generic.ID` across *every* pak, and as a record name: 0 hits.
`UN_f002_TCAF_DeltaSaber_W` does exist, and `Rhino1-Katana` uses
`UN_f002_TCAF_DeltaSaber_W_Player`. **Consistent with a shipped `f001`/`f002`
typo** — but what the game does when the lookup fails is not shown here, so
this is reported, not diagnosed.
* **`Rhino4-Brandon.UnitID = BRANDON`** — no `CharacterBRANDON` among the 64.
`CharacterKATANA` does exist, and Katana is the player.
Both are single rows against 13 that resolve, so they are exceptions in the
data, not a wrong reading of the field.
## 🟡 Not settled
* Who *selects* a loadout record. The names encode flight position and pilot,
and [the stage definition table](stage-definition-table.md) has
`AI_TCAF_RhinoFlight` / `AI_TCAF_BirdFlight` — the join is not shown here.
* `ExSET_*`, `S02SET_*` and `NULL_*` are referenced by no loadout in this pak.
* The `PlayerUnit` field is present but empty on all three `-Katana` rows.
* `IGNORE` (13 item names) — a blocklist for something unread.

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""The Hangar loadout system: loadout -> per-slot allow-list -> arsenal item.
A loadout record's `Arm1`/`Arm2`/`Arm3`/`Nose` do NOT name items. They name a
per-slot ALLOW-LIST record whose positional (unnamed) fields are the ordered
candidate item names. Regenerates docs/re/data/hangar-loadouts.txt.
"""
import sys, os, glob, collections
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import unitgroup as U
from unit_substructures import pak_entries
ITEMF = {'Dependency', 'MissionObjective', 'Model', 'Package',
'PlayerWeapon', 'Points', 'Power', 'Range'}
SLOTS = ('Arm1', 'Arm2', 'Arm3', 'Nose')
def main():
ars = glob.glob('/work/sylph_extract/**/GP_HANGAR_ARSENAL.pak', recursive=True)[0]
unit_ids, char_ids = set(), set()
for pk in sorted(glob.glob('/work/sylph_extract/**/*.pak', recursive=True)):
for h, b in pak_entries(pk):
if b[:4] != b'IDXD': continue
try: recs = U.parse(b)
except Exception: continue
for r in recs:
if r['squadron'] != 'Generic': continue
n = U.named(r)
if 'HP' in n and n.get('ID'): unit_ids.add(n['ID'])
elif 'SideID' in n and n.get('ID'): char_ids.add(n['ID'])
items, loadouts, allow, roster = {}, {}, {}, None
for h, b in pak_entries(ars):
if b[:4] != b'IDXD': continue
try: recs = U.parse(b)
except Exception: continue
for r in recs:
n = U.named(r); nm = r['squadron']
if nm == 'WEAPONS' and roster is None: roster = r
if ITEMF <= set(n): items.setdefault(nm, r)
elif set(SLOTS) <= set(n): loadouts.setdefault(nm, r)
elif set(n) == {'Type'}: allow.setdefault(nm, r)
print("# The Hangar loadout system")
print("# Regenerate: python3 tools/re-capture/hangar_loadouts.py")
print("# See docs/re/structures/hangar-loadout-system.md")
print("\n## counts")
print(" arsenal items %3d loadout records %3d per-slot allow-lists %3d"
% (len(items), len(loadouts), len(allow)))
print("\n## loadout records")
for nm in sorted(loadouts):
n = U.named(loadouts[nm])
u = n.get('UnitID', '')
kind = 'unit' if u in unit_ids else ('character' if 'Character' + u in char_ids else '???')
print(" %-16s %-16s %-16s %-16s %-16s UnitID=%-34s (%s)%s"
% (nm, n.get('Arm1'), n.get('Arm2'), n.get('Arm3'), n.get('Nose'), u, kind,
' PlayerUnit' if 'PlayerUnit' in n else ''))
tot = hit = 0
for r in loadouts.values():
n = U.named(r)
for f in SLOTS:
tot += 1; hit += n.get(f, '').strip() in allow
print("\n## CONTROL loadout.Arm1/2/3/Nose names an ALLOW-LIST record")
print(" %d/%d" % (hit, tot))
ptot = phit = 0
miss = collections.Counter()
for r in allow.values():
for t, n, v in r['fields']:
if n is not None: continue
ptot += 1
if v in items: phit += 1
else: miss[v] += 1
rset = {v for _, _, v in roster['fields']}
print("\n## CONTROL allow-list positional entries name an ARSENAL ITEM")
print(" %d/%d" % (phit, ptot))
print(" misses: %s" % miss.most_common())
print(" every miss is a WEAPONS-roster sentinel with no item record: %s"
% (set(miss) <= (rset - set(items))))
print("\n## CONTROL loadout.UnitID")
u = [U.named(r).get('UnitID', '').strip() for r in loadouts.values()]
print(" %d loadouts: %d name a unit Generic.ID, %d name a character (as 'Character'+value), %d neither"
% (len(u),
sum(1 for x in u if x in unit_ids),
sum(1 for x in u if 'Character' + x in char_ids),
sum(1 for x in u if x not in unit_ids and 'Character' + x not in char_ids)))
print(" neither: %s" % sorted({x for x in u if x not in unit_ids and 'Character' + x not in char_ids}))
print("\n## the allow-lists, in order")
for nm in sorted(allow):
vs = [v for t, n, v in allow[nm]['fields'] if n is None]
print(" %-16s Type=%-5s %2d: %s" % (nm, U.named(allow[nm]).get('Type'), len(vs), ", ".join(vs)))
if __name__ == "__main__":
main()