diff --git a/docs/re/BACKLOG.md b/docs/re/BACKLOG.md index 131d4a4..f877a9b 100644 --- a/docs/re/BACKLOG.md +++ b/docs/re/BACKLOG.md @@ -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), diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index b2a970d..a15c263 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -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 | diff --git a/docs/re/data/hangar-loadouts.txt b/docs/re/data/hangar-loadouts.txt new file mode 100644 index 0000000..1abcdec --- /dev/null +++ b/docs/re/data/hangar-loadouts.txt @@ -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 diff --git a/docs/re/structures/hangar-loadout-system.md b/docs/re/structures/hangar-loadout-system.md new file mode 100644 index 0000000..f5efe1c --- /dev/null +++ b/docs/re/structures/hangar-loadout-system.md @@ -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. diff --git a/tools/re-capture/hangar_loadouts.py b/tools/re-capture/hangar_loadouts.py new file mode 100644 index 0000000..1f84850 --- /dev/null +++ b/tools/re-capture/hangar_loadouts.py @@ -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()